rustrade_data/exchange/ibkr/flex/mod.rs
1//! IBKR **Flex Web Service** corporate-action reconciliation.
2//!
3//! Fetches an account's *Activity* Flex statement over HTTPS and exposes its Corporate Actions
4//! section as faithful [`IbkrFlexCorporateAction`] records. This is a **reconciliation / audit**
5//! surface — broker-confirmed, account-scoped, and post-hoc — *not* a market-reference split source.
6//! It does **not** derive split ratios; a caller that wants a ratio cross-references a dedicated
7//! split source (and owns reconcile policy). See [`corporate_action`] for the record contract.
8//!
9//! # Transport (2-call Flex Web Service flow)
10//!
11//! The Flex Web Service generates statements asynchronously, so a fetch is two calls:
12//!
13//! 1. **SendRequest** — `GET {base}/SendRequest?t={token}&q={queryId}&v=3` returns a
14//! `<FlexStatementResponse>` with a `ReferenceCode` and a `Url` to poll (or a `Fail` status).
15//! 2. **GetStatement** — `GET {Url}?t={token}&q={referenceCode}&v=3`, polled with a bounded number
16//! of attempts while the service replies `Warn`/`ErrorCode 1019` ("generation in progress"),
17//! until it returns the `<FlexQueryResponse>` statement (or a terminal error).
18//!
19//! Non-success statuses (e.g. `1003` invalid token, `1018` throttled, token-expired) surface as
20//! [`IbkrFlexError::Flex`]; a non-success HTTP status whose body is *not* a recognizable Flex
21//! envelope (e.g. a proxy/CDN error page) surfaces as [`IbkrFlexError::HttpStatus`]; exhausting the
22//! poll budget surfaces as [`IbkrFlexError::PollTimeout`].
23//!
24//! This service uses an HTTPS token + saved-query id — it does **not** use IB Gateway / TWS, so it
25//! is entirely independent of the socket [`IbkrStreamConfig`](crate::exchange::ibkr::IbkrStreamConfig).
26//!
27//! # Known limitations
28//!
29//! - **Post-hoc (T+1+).** A Flex statement reports actions *after* the broker books them; the
30//! records are for reconciliation/audit, not for injecting split events into a live/backtest
31//! timeline at the market execution instant.
32//! - **Account-scoped.** Quantities are this account's deltas, not market-wide figures.
33//! - **No library-derived ratio.** Flex carries no standardised split-ratio field; the wrapper
34//! derives/verifies ratios from a market-reference source.
35//! - **Query-configuration-dependent date format.** The raw `dateTime` format depends on the saved
36//! query's settings (see [`IbkrFlexCorporateAction::date_time`]).
37//! - **The saved Flex query must include the Corporate Actions section** over an account-activity
38//! scope, otherwise the statement contains no `<CorporateAction>` rows to return.
39//!
40//! # Credentials
41//!
42//! Construct from a [`IbkrFlexConfig`] (an Activity-statement Flex `token` + saved-query `query_id`).
43//! [`IbkrFlexClient::from_env`] reads `IBKR_FLEX_TOKEN` and `IBKR_FLEX_QUERY_ID`.
44
45mod corporate_action;
46
47pub use corporate_action::{IbkrFlexCorporateAction, IbkrReorgType, parse_corporate_actions};
48
49use crate::exchange::http::{MAX_ERROR_BODY_DOWNLOAD_BYTES, read_body_capped, truncate_str};
50use quick_xml::{Reader, events::Event};
51use serde::Deserialize;
52use smol_str::SmolStr;
53use std::{env, fmt, time::Duration};
54use thiserror::Error;
55use tracing::debug;
56
57/// Base URL of the IBKR Flex Web Service (`SendRequest` lives directly under it; the poll URL is
58/// taken from the SendRequest response, not hard-coded).
59const FLEX_BASE_URL: &str =
60 "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService";
61
62/// Flex Web Service protocol version (`v` query parameter).
63const FLEX_VERSION: &str = "3";
64
65/// Timeout for each individual HTTP request.
66const REQUEST_TIMEOUT_SECS: u64 = 30;
67
68/// Maximum number of GetStatement poll attempts before giving up.
69const POLL_MAX_ATTEMPTS: u32 = 12;
70
71/// Delay between GetStatement poll attempts while the statement is still generating.
72const POLL_INTERVAL: Duration = Duration::from_secs(5);
73
74/// Delay before the *first* GetStatement poll. The statement is generated asynchronously, so the
75/// first poll almost always reports [`GENERATION_IN_PROGRESS_CODE`]; waiting once up front keeps
76/// that near-certain miss from consuming one of the bounded poll attempts. Overridable via
77/// [`FlexPollPolicy`].
78const POLL_INITIAL_DELAY: Duration = Duration::from_secs(5);
79
80/// Flex `ErrorCode` meaning "statement generation in progress, try again shortly" — the only
81/// non-success status that should be retried rather than surfaced as an error.
82const GENERATION_IN_PROGRESS_CODE: &str = "1019";
83
84/// Maximum number of bytes of a non-Flex error body retained in [`IbkrFlexError::HttpStatus`]. Flex
85/// and IBKR error envelopes are tiny; this only bounds an unexpectedly large proxy/CDN error page.
86const MAX_ERROR_BODY_BYTES: usize = 1024;
87
88// The redact-before-bound ordering in `sanitize_error_body` is only safe while the *gap* between the
89// download and retained caps exceeds the token length. A token straddling the coarse
90// `MAX_ERROR_BODY_DOWNLOAD_BYTES` pre-bound survives redaction as an unmatched prefix fragment pinned
91// to the tail of the pre-bounded slice: it ends at the download cap, so a fragment of length `f`
92// starts at `MAX_ERROR_BODY_DOWNLOAD_BYTES - f`. The final `MAX_ERROR_BODY_BYTES` truncation discards
93// it when that start lands at or past the retained cap — i.e. when `f` is at most the gap
94// `MAX_ERROR_BODY_DOWNLOAD_BYTES - MAX_ERROR_BODY_BYTES`. A straddling fragment is always shorter than
95// the whole token, so once the gap exceeds the longest possible token every such fragment is
96// truncated away. That is the invariant.
97//
98// The assert below is a proportional proxy for that gap, not a proof for arbitrary token lengths: a
99// 4x ratio keeps the gap at a floor of 3 * MAX_ERROR_BODY_BYTES (3 KiB at the current retained cap;
100// the actual current gap is far larger), which dwarfs any real Flex token (a short opaque string).
101// Its job is to fail the build — the two constants live in different modules — if a future edit
102// collapses the caps close enough together to shrink that margin below a plausible token, silently
103// reopening the straddling leak.
104const _: () = assert!(
105 MAX_ERROR_BODY_DOWNLOAD_BYTES >= MAX_ERROR_BODY_BYTES * 4,
106 "MAX_ERROR_BODY_DOWNLOAD_BYTES must stay well above MAX_ERROR_BODY_BYTES so a token straddling \
107 the download-cap boundary is truncated away by the retained-cap bound (see sanitize_error_body)"
108);
109
110/// Errors from IBKR Flex Web Service operations.
111///
112/// `#[non_exhaustive]`: the Flex service may introduce new failure conditions over time, so new
113/// variants can be added without a breaking change; downstream `match`es must include a wildcard arm.
114#[non_exhaustive]
115#[derive(Debug, Error)]
116pub enum IbkrFlexError {
117 /// A required environment variable is not set (see [`IbkrFlexConfig::from_env`]).
118 #[error("environment variable error: {0}")]
119 EnvVar(String),
120
121 /// A supplied credential (the Flex `token` or `query_id`) is empty after trimming surrounding
122 /// whitespace. Raised by [`IbkrFlexConfig::new`] so a malformed credential fails observably at
123 /// construction, naming the offending field, rather than later as an opaque IBKR `1003`
124 /// "invalid token" after a live fetch.
125 #[error("invalid credential: {0}")]
126 InvalidCredential(String),
127
128 /// Transport-level HTTP error (connection, timeout, or body-decode failure).
129 ///
130 /// A non-2xx HTTP *status* no longer surfaces here: the client reads the response body **before**
131 /// branching on status and reports a non-success status as [`IbkrFlexError::HttpStatus`] (or the
132 /// richer [`IbkrFlexError::Flex`] when the body is a Flex error envelope). This variant is now
133 /// purely a connection/timeout/decode failure.
134 ///
135 /// The inner [`reqwest::Error`] **never carries the request URL.** The Flex token is
136 /// transmitted as `t=<TOKEN>` in every request URL, and `reqwest::Error`'s `Display` and
137 /// `Debug` both embed that URL verbatim when present. The `From<reqwest::Error>` conversion
138 /// for this type always strips it via [`reqwest::Error::without_url`] before the error is
139 /// stored, covering every reqwest site that attaches a URL (`send`, body decode);
140 /// `Client::build()` errors carry no URL, so the strip is a no-op for them. The `source()`
141 /// chain (hyper/IO transport errors) is preserved and does not independently carry the URL.
142 ///
143 /// Safe to log or display without credential scrubbing.
144 #[error("HTTP error: {0}")]
145 Http(reqwest::Error),
146
147 /// The service returned a non-success HTTP status whose body was **not** a recognizable Flex
148 /// envelope — e.g. a proxy/CDN/WAF error page or a gateway timeout served in front of the Flex
149 /// Web Service.
150 ///
151 /// The body is read before the status is inspected (so the diagnostic is preserved rather than
152 /// discarded by `error_for_status`), then bounded to 1 KiB and
153 /// **token-scrubbed** before storage: the Flex token rides in the request URL's `t=` parameter,
154 /// so a proxy that echoes the request line into its error page could otherwise reflect the
155 /// credential into this body. The scrub is best-effort defense-in-depth — it redacts both the
156 /// raw token and the encoded form it takes on the wire. An IBKR
157 /// *application* error that arrives under a non-2xx status is still surfaced as the richer
158 /// [`IbkrFlexError::Flex`], not here.
159 #[error("HTTP status {status}: {body}")]
160 HttpStatus {
161 /// The non-success HTTP status code.
162 status: u16,
163 /// A bounded, token-scrubbed slice of the response body.
164 body: String,
165 },
166
167 /// The Flex service returned a non-success status (e.g. invalid token, throttled, expired).
168 ///
169 /// `message` is the `<ErrorMessage>` element lifted from a server-supplied body, so — like
170 /// [`IbkrFlexError::HttpStatus`] and [`IbkrFlexError::Parse`] — both fields are bounded to 1 KiB
171 /// and **token-scrubbed** before storage: a proxy that reflected the request line into its
172 /// `<ErrorMessage>` could otherwise carry the `t=` Flex token into it. The scrub is a no-op for a
173 /// real numeric Flex code/message that does not contain the token.
174 #[error("Flex service error ({code}): {message}")]
175 Flex {
176 /// The Flex `ErrorCode` (empty if the response carried none), bounded and token-scrubbed
177 /// like `message`.
178 code: String,
179 /// The Flex `ErrorMessage` (or a synthesised description), bounded and token-scrubbed.
180 message: String,
181 },
182
183 /// The statement was still generating after the configured poll budget was exhausted.
184 #[error("Flex statement not ready after {attempts} poll attempts")]
185 PollTimeout {
186 /// Number of poll attempts made before giving up — the configured
187 /// [`FlexPollPolicy::max_attempts`] (12 by default), since this error
188 /// fires only after every attempt reported the statement still generating.
189 attempts: u32,
190 },
191
192 /// The statement or status XML could not be parsed.
193 ///
194 /// A parse message embeds the deserialiser's own rendering of the offending input, so its
195 /// length tracks the *document* rather than the failure, and a document that reflected the
196 /// request line could carry the `t=` credential into it. This variant is therefore **always
197 /// bounded** to 1 KiB, and **token-scrubbed** on every path where a token is in scope — the
198 /// same protection [`IbkrFlexError::HttpStatus`] gets, for the same reason.
199 ///
200 /// Scrubbing covers every `Parse` returned by [`IbkrFlexClient::fetch_statement_xml`] and
201 /// [`IbkrFlexClient::fetch_corporate_actions`]. The one path it cannot cover is calling
202 /// [`parse_corporate_actions`] directly: there the caller supplies its own XML and no token
203 /// exists to redact. Such a message is still bounded — just not scrubbed, since there is
204 /// nothing to scrub against.
205 #[error("Flex XML parse error: {0}")]
206 Parse(String),
207}
208
209/// Convert a [`reqwest::Error`] into [`IbkrFlexError::Http`] **with its request URL stripped**.
210///
211/// The Flex `token` rides in the `t=` query parameter (IBKR's protocol), so it is part of every
212/// request URL. `reqwest::Error`'s `Display`/`Debug` embed the stored URL verbatim — e.g.
213/// `"... for url (https://.../SendRequest?t=<TOKEN>&q=...)"` — so an unstripped error would leak the
214/// credential into any log that formats [`IbkrFlexError::Http`] (the shipped example does exactly
215/// that).
216///
217/// Stripping lives in this single conversion rather than per-call-site discipline so the
218/// "[`IbkrFlexError::Http`] never carries the URL" invariant is enforced by the type system: every
219/// `?` on a `reqwest::Error` — at the request sites (`send`, body decode) *and* any path added later
220/// — routes through here.
221/// [`reqwest::Error::without_url`] sets the stored URL to `None` (a no-op for `Client::build()`
222/// errors, which carry none) while preserving the diagnostic kind/status and `source()` chain.
223impl From<reqwest::Error> for IbkrFlexError {
224 fn from(error: reqwest::Error) -> Self {
225 IbkrFlexError::Http(error.without_url())
226 }
227}
228
229/// Poll timing for the GetStatement stage of [`IbkrFlexClient::fetch_statement_xml`].
230///
231/// The Flex Web Service generates statements asynchronously, so a fetch polls GetStatement until the
232/// statement is ready. This policy controls that polling:
233///
234/// - `initial_delay` — waited **once** before the first poll. The statement is created
235/// asynchronously, so the first GetStatement almost always reports `1019` ("still generating"); an
236/// initial delay keeps that near-certain miss from consuming one of the bounded attempts.
237/// - `interval` — waited between subsequent polls.
238/// - `max_attempts` — the number of GetStatement polls before giving up with
239/// [`IbkrFlexError::PollTimeout`].
240///
241/// The [`Default`] budget (`initial_delay` 5 s, `interval` 5 s, `max_attempts` 12) suits a typical
242/// small activity statement. A caller fetching a large statement — which takes longer to generate —
243/// can widen the budget via [`IbkrFlexClient::with_poll_policy`]. A `max_attempts` of `0` yields an
244/// immediate [`IbkrFlexError::PollTimeout`] (an observable "no polling requested" outcome).
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct FlexPollPolicy {
247 /// Delay before the first GetStatement poll.
248 pub initial_delay: Duration,
249 /// Delay between subsequent GetStatement polls.
250 pub interval: Duration,
251 /// Maximum number of GetStatement poll attempts before [`IbkrFlexError::PollTimeout`].
252 pub max_attempts: u32,
253}
254
255impl Default for FlexPollPolicy {
256 fn default() -> Self {
257 Self {
258 initial_delay: POLL_INITIAL_DELAY,
259 interval: POLL_INTERVAL,
260 max_attempts: POLL_MAX_ATTEMPTS,
261 }
262 }
263}
264
265/// Credentials for the IBKR Flex Web Service: an Activity-statement Flex `token` and the id of a
266/// saved Flex query that includes the Corporate Actions section.
267///
268/// `Debug` redacts the token so it never leaks through tracing or panic output.
269#[derive(Clone)]
270pub struct IbkrFlexConfig {
271 token: String,
272 query_id: String,
273}
274
275impl fmt::Debug for IbkrFlexConfig {
276 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277 f.debug_struct("IbkrFlexConfig")
278 .field("token", &"[REDACTED]")
279 .field("query_id", &self.query_id)
280 .finish()
281 }
282}
283
284impl IbkrFlexConfig {
285 /// Create a config from an explicit Flex token and saved-query id.
286 ///
287 /// Both values are **trimmed** of surrounding whitespace and rejected if empty (or empty after
288 /// trimming), so a malformed credential fails here — observably, naming the offending field —
289 /// rather than later as an opaque IBKR `1003` "invalid token" at fetch time. This mirrors the
290 /// validation [`from_env`](Self::from_env) performs, and matches the fallible-`new` shape used
291 /// by the other exchange clients in this crate.
292 ///
293 /// # Errors
294 ///
295 /// Returns [`IbkrFlexError::InvalidCredential`] if either value is empty after trimming.
296 pub fn new(
297 token: impl Into<String>,
298 query_id: impl Into<String>,
299 ) -> Result<Self, IbkrFlexError> {
300 Ok(Self {
301 token: Self::require_nonempty_trimmed("token", token.into())?,
302 query_id: Self::require_nonempty_trimmed("query_id", query_id.into())?,
303 })
304 }
305
306 /// Create a config from environment variables.
307 ///
308 /// Reads `IBKR_FLEX_TOKEN` (the Flex Web Service token) and `IBKR_FLEX_QUERY_ID` (the saved
309 /// query id), both required.
310 ///
311 /// # Errors
312 ///
313 /// Returns [`IbkrFlexError::EnvVar`] if either variable is missing or set but empty.
314 pub fn from_env() -> Result<Self, IbkrFlexError> {
315 let token = env::var("IBKR_FLEX_TOKEN")
316 .map_err(|e| IbkrFlexError::EnvVar(format!("IBKR_FLEX_TOKEN: {e}")))?;
317 let query_id = env::var("IBKR_FLEX_QUERY_ID")
318 .map_err(|e| IbkrFlexError::EnvVar(format!("IBKR_FLEX_QUERY_ID: {e}")))?;
319 // Trim before storing so surrounding whitespace can't silently end up in the `t=`/`q=`
320 // query parameters (which IBKR would reject with a confusing 1003 "invalid token" at fetch
321 // time rather than a clear configuration error here).
322 let token = token.trim();
323 let query_id = query_id.trim();
324 if token.is_empty() {
325 return Err(IbkrFlexError::EnvVar(
326 "IBKR_FLEX_TOKEN is set but empty".to_owned(),
327 ));
328 }
329 if query_id.is_empty() {
330 return Err(IbkrFlexError::EnvVar(
331 "IBKR_FLEX_QUERY_ID is set but empty".to_owned(),
332 ));
333 }
334 // Route through `new` so there is a single construction/validation path. The env-var checks
335 // above already produce variable-specific messages (`IBKR_FLEX_TOKEN is set but empty`),
336 // which are more actionable than `new`'s generic field-level error, so they run first; the
337 // re-trim/re-check inside `new` is then a cheap no-op.
338 Self::new(token, query_id)
339 }
340
341 /// Trim `value` and reject it if empty (or empty after trimming), tagging the error with
342 /// `field` so the failure names which credential was malformed. Allocates a new `String` only
343 /// when trimming actually removed characters.
344 fn require_nonempty_trimmed(
345 field: &'static str,
346 value: String,
347 ) -> Result<String, IbkrFlexError> {
348 let trimmed = value.trim();
349 if trimmed.is_empty() {
350 return Err(IbkrFlexError::InvalidCredential(format!(
351 "{field} is empty after trimming whitespace"
352 )));
353 }
354 if trimmed.len() == value.len() {
355 Ok(value)
356 } else {
357 Ok(trimmed.to_owned())
358 }
359 }
360}
361
362/// Client for fetching corporate-action records from the IBKR Flex Web Service.
363///
364/// `Debug` omits both the token (a credential) and the [`reqwest::Client`], so neither can leak.
365#[derive(Clone)]
366pub struct IbkrFlexClient {
367 http: reqwest::Client,
368 token: String,
369 query_id: String,
370 base_url: String,
371 poll_policy: FlexPollPolicy,
372}
373
374impl fmt::Debug for IbkrFlexClient {
375 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376 // Deliberately omits the token (a credential) and the `reqwest::Client`.
377 f.debug_struct("IbkrFlexClient")
378 .field("query_id", &self.query_id)
379 .field("base_url", &self.base_url)
380 .field("poll_policy", &self.poll_policy)
381 .finish_non_exhaustive()
382 }
383}
384
385impl IbkrFlexClient {
386 /// Create a client from a [`IbkrFlexConfig`].
387 ///
388 /// # Errors
389 ///
390 /// Returns [`IbkrFlexError::Http`] if the underlying HTTP client cannot be built.
391 pub fn new(config: IbkrFlexConfig) -> Result<Self, IbkrFlexError> {
392 let http = reqwest::Client::builder()
393 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
394 // Transport-layer token protection. The Flex `token` rides in every request URL's `t=`
395 // query parameter, so it must never traverse an unencrypted connection. Two guards work
396 // together: `redirect::Policy::none()` stops reqwest auto-following any redirect, so a 3xx
397 // can never bounce the token to another (possibly `http://`) URL behind our back; and
398 // `https_only(true)` rejects any request whose own URL is not `https`, catching a
399 // misconfigured `http://` base URL before the token reaches the wire. This client issues
400 // two GETs to known HTTPS endpoints and expects no redirects, so an unexpected 3xx is
401 // returned unfollowed and surfaces downstream as an `IbkrFlexError::HttpStatus` (a
402 // non-success status with a non-Flex body) — never as a silent scheme downgrade. (The
403 // content-layer scheme check on the SendRequest poll URL cannot
404 // intercept redirects reqwest would otherwise follow, which is why these guards exist.)
405 .https_only(true)
406 .redirect(reqwest::redirect::Policy::none())
407 .build()?;
408 Ok(Self {
409 http,
410 token: config.token,
411 query_id: config.query_id,
412 base_url: FLEX_BASE_URL.to_owned(),
413 poll_policy: FlexPollPolicy::default(),
414 })
415 }
416
417 /// Override the GetStatement [`FlexPollPolicy`] (poll timing / budget).
418 ///
419 /// Builder-style; defaults to [`FlexPollPolicy::default`]. Widen the budget for a large
420 /// statement (which takes longer to generate):
421 ///
422 /// ```no_run
423 /// # use std::time::Duration;
424 /// # use rustrade_data::exchange::ibkr::{IbkrFlexClient, FlexPollPolicy};
425 /// # fn f() -> Result<(), Box<dyn std::error::Error>> {
426 /// let _client = IbkrFlexClient::from_env()?.with_poll_policy(FlexPollPolicy {
427 /// initial_delay: Duration::from_secs(10),
428 /// interval: Duration::from_secs(10),
429 /// max_attempts: 30,
430 /// });
431 /// # Ok(())
432 /// # }
433 /// ```
434 #[must_use]
435 pub fn with_poll_policy(mut self, policy: FlexPollPolicy) -> Self {
436 self.poll_policy = policy;
437 self
438 }
439
440 /// Create a client from environment variables (see [`IbkrFlexConfig::from_env`]).
441 ///
442 /// # Errors
443 ///
444 /// Returns [`IbkrFlexError::EnvVar`] if a required variable is missing, or
445 /// [`IbkrFlexError::Http`] if the HTTP client cannot be built.
446 pub fn from_env() -> Result<Self, IbkrFlexError> {
447 Self::new(IbkrFlexConfig::from_env()?)
448 }
449
450 /// Run the full 2-call Flex flow and return the raw `<FlexQueryResponse>` statement XML.
451 ///
452 /// Issues SendRequest, then polls GetStatement until the statement is ready, governed by this
453 /// client's [`FlexPollPolicy`] (an `initial_delay` before the first poll, `interval` between
454 /// subsequent polls, and a `max_attempts` budget). Defaults to [`FlexPollPolicy::default`];
455 /// override with [`with_poll_policy`](Self::with_poll_policy).
456 ///
457 /// # Security
458 ///
459 /// The Flex `token` is transmitted as the `t=` URL **query parameter** (as IBKR's protocol
460 /// requires), so it is part of every request URL. Do not attach request-logging middleware to
461 /// the underlying [`reqwest::Client`] that records full URLs, or the token will be captured in
462 /// plaintext. The poll URL returned by SendRequest is required to be `https://` for the same
463 /// reason — the token must never traverse an unencrypted connection.
464 ///
465 /// For the same reason, [`IbkrFlexError::Http`] must never embed the request URL: a
466 /// `reqwest::Error`'s `Display` includes the URL it was building, which would carry `t=<TOKEN>`
467 /// into any log that formats the error. The `From<reqwest::Error>` conversion for
468 /// [`IbkrFlexError`] strips the URL via [`reqwest::Error::without_url`] before the error is
469 /// stored, so a plain `?` is safe here and in any request path added later.
470 ///
471 /// Two IBKR hosts are involved: `ndcdyn.interactivebrokers.com` (the hard-coded `FLEX_BASE_URL`
472 /// that serves `SendRequest`) and `gdcdyn.interactivebrokers.com` (the statement-download host
473 /// that `SendRequest` returns in its poll URL). The poll host is deliberately **not** pinned to
474 /// an allowlist — only its scheme is enforced (`https://`) — so IBKR can relocate statement
475 /// serving without breaking this client, while the `https://` gate keeps the token encrypted
476 /// regardless of which host is returned.
477 ///
478 /// # Errors
479 ///
480 /// - [`IbkrFlexError::Http`] on a transport failure.
481 /// - [`IbkrFlexError::Flex`] if the service reports a terminal non-success status.
482 /// - [`IbkrFlexError::HttpStatus`] if a non-success HTTP status carries a body that is not a
483 /// recognizable Flex envelope (e.g. a proxy/CDN error page).
484 /// - [`IbkrFlexError::PollTimeout`] if the statement is still generating after the poll budget.
485 /// - [`IbkrFlexError::Parse`] if a response cannot be parsed.
486 pub async fn fetch_statement_xml(&self) -> Result<String, IbkrFlexError> {
487 let send_url = format!("{}/SendRequest", self.base_url);
488 debug!("Requesting IBKR Flex statement generation");
489
490 let (status, body) = self
491 .get_with_query(&send_url, self.query_id.as_str())
492 .await?;
493 let SendRequestOk {
494 reference_code,
495 url,
496 } = interpret_send_response(status, &body, &self.token)?;
497
498 poll_until_ready(&self.poll_policy, &self.token, async || {
499 self.get_with_query(&url, reference_code.as_str()).await
500 })
501 .await
502 }
503
504 /// Issue a GET to `url` with the standard Flex query parameters (`t`/`q`/`v`) and read the
505 /// response body **regardless of HTTP status**, returning the status alongside the body.
506 ///
507 /// Reading the body before branching on status is deliberate: `error_for_status` discards the
508 /// body, but IBKR/proxy diagnostic bodies — and Flex application errors (e.g. `1019`) that arrive
509 /// under a non-2xx status — are exactly what a caller needs to diagnose a failure. The status is
510 /// returned so the caller ([`interpret_send_response`] / [`interpret_poll_response`]) can decide
511 /// whether an unrecognized body under a non-success status is a transport error
512 /// ([`IbkrFlexError::HttpStatus`]) versus a genuine Flex-envelope error.
513 ///
514 /// A **success** body is the statement payload (a `<FlexQueryResponse>`, which can be large) and
515 /// is read in full. A **non-success** body is only a small Flex status/error envelope or a
516 /// proxy/CDN diagnostic page — never the statement — so it is read only up to
517 /// `MAX_ERROR_BODY_DOWNLOAD_BYTES`, bounding a pathological error page that would otherwise be
518 /// buffered without limit (it is truncated further for storage anyway). The cap is far above any
519 /// real Flex envelope, so a `1019`/error response the poll loop still parses is never truncated.
520 ///
521 /// The `?` on `send`/`chunk`/`text` still routes any `reqwest::Error` through the URL-stripping
522 /// `From<reqwest::Error>` impl, so the token never leaks into an error.
523 async fn get_with_query(
524 &self,
525 url: &str,
526 q: &str,
527 ) -> Result<(reqwest::StatusCode, String), IbkrFlexError> {
528 let response = self
529 .http
530 .get(url)
531 .query(&[("t", self.token.as_str()), ("q", q), ("v", FLEX_VERSION)])
532 .send()
533 .await?;
534 read_status_and_body(response).await
535 }
536
537 /// Fetch the statement and parse its Corporate Actions section into faithful records.
538 ///
539 /// Convenience over [`fetch_statement_xml`](Self::fetch_statement_xml) +
540 /// [`parse_corporate_actions`]. Returns **all** reorg rows; filtering to splits is the caller's
541 /// job. See [`IbkrFlexCorporateAction`] for the record contract and limitations.
542 ///
543 /// # Errors
544 ///
545 /// Same as [`fetch_statement_xml`](Self::fetch_statement_xml).
546 pub async fn fetch_corporate_actions(
547 &self,
548 ) -> Result<Vec<IbkrFlexCorporateAction>, IbkrFlexError> {
549 let xml = self.fetch_statement_xml().await?;
550 // The token is threaded *into* the parse rather than scrubbed off its result: redaction
551 // matches the full token, so it must run before the message is bounded, and
552 // `parse_corporate_actions` bounds before returning. Scrubbing afterwards would leave a
553 // credential straddling the cap present only as an unmatched prefix fragment. This re-parses
554 // a body that already crossed the network, and a misconfigured proxy that echoed the request
555 // line into it would have put `t=<TOKEN>` in reach of a deserialiser message.
556 corporate_action::parse_corporate_actions_scrubbed(&xml, Some(&self.token))
557 }
558}
559
560/// Read a response's HTTP status and body, bounding the read on the error path.
561///
562/// Split out of [`IbkrFlexClient::get_with_query`] so this contract can be exercised against a
563/// synthetic [`reqwest::Response`] with no live socket (reqwest exposes
564/// `From<http::Response<_>>`), which the caller's `send()` cannot be. That matters because the
565/// ordering here is the load-bearing part: the body is read **before** the status is judged, so a
566/// Flex envelope arriving under a non-2xx status — notably the retryable `1019` "still generating"
567/// — survives to be classified. `error_for_status()?.text().await?` would discard it and abort the
568/// poll, and every test that drives [`interpret_poll_response`] with a synthetic `(status, body)`
569/// tuple would still pass. See `read_status_and_body_*` in this module's tests.
570///
571/// A **success** body is the statement payload (a `<FlexQueryResponse>`, which can be large) and is
572/// read in full. A **non-success** body is only a small Flex status/error envelope or a proxy/CDN
573/// diagnostic page — never the statement — so it is capped at [`MAX_ERROR_BODY_DOWNLOAD_BYTES`],
574/// far above any real Flex envelope.
575async fn read_status_and_body(
576 response: reqwest::Response,
577) -> Result<(reqwest::StatusCode, String), IbkrFlexError> {
578 let status = response.status();
579 let body = if status.is_success() {
580 response.text().await?
581 } else {
582 read_body_capped(response, MAX_ERROR_BODY_DOWNLOAD_BYTES).await?
583 };
584 Ok((status, body))
585}
586
587/// Poll GetStatement until the statement is ready or `policy`'s attempt budget is exhausted,
588/// delegating each raw fetch to `poll_once`.
589///
590/// Isolating the loop from the transport keeps its orchestration deterministically testable without
591/// a network round trip: `poll_once` is any async producer of a `(status, body)` pair, so a scripted
592/// responder can drive the exact wiring — the `max_attempts == 0` immediate-timeout case, the
593/// `1..=max_attempts` bound, and the `attempt < max_attempts` inter-poll sleep guard — under
594/// zero-duration policies. (The live two-call round trip stays covered by the `#[ignore]`d network
595/// test.)
596///
597/// The `initial_delay` is waited once before the first poll: the statement is generated
598/// asynchronously, so an immediate GetStatement almost always reports `1019` ("still generating") and
599/// would burn one of the bounded attempts on a near-certain miss. It is skipped when no polls are
600/// budgeted (`max_attempts == 0`), so that documented "no polling requested" case fails over
601/// immediately with [`IbkrFlexError::PollTimeout`] rather than sleeping before an empty loop.
602async fn poll_until_ready<F>(
603 policy: &FlexPollPolicy,
604 token: &str,
605 mut poll_once: F,
606) -> Result<String, IbkrFlexError>
607where
608 F: AsyncFnMut() -> Result<(reqwest::StatusCode, String), IbkrFlexError>,
609{
610 if policy.max_attempts > 0 {
611 tokio::time::sleep(policy.initial_delay).await;
612 }
613
614 for attempt in 1..=policy.max_attempts {
615 let (status, body) = poll_once().await?;
616 match interpret_poll_response(status, &body, token)? {
617 GetStatementOutcome::Ready => return Ok(body),
618 GetStatementOutcome::InProgress => {
619 debug!(
620 attempt,
621 max = policy.max_attempts,
622 "Flex statement still generating"
623 );
624 if attempt < policy.max_attempts {
625 tokio::time::sleep(policy.interval).await;
626 }
627 }
628 }
629 }
630
631 Err(IbkrFlexError::PollTimeout {
632 attempts: policy.max_attempts,
633 })
634}
635
636// ============================================================================
637// Pure, testable response classification
638// ============================================================================
639
640/// A successful SendRequest result: the reference code + poll URL for GetStatement.
641#[derive(Debug)]
642struct SendRequestOk {
643 reference_code: String,
644 url: String,
645}
646
647/// The outcome of classifying a GetStatement poll response. A terminal failure is returned as an
648/// `Err` from [`classify_get_statement`], so this only models the two non-error outcomes.
649#[derive(Debug, PartialEq, Eq)]
650enum GetStatementOutcome {
651 /// The body is a `<FlexQueryResponse>` statement, ready to parse.
652 Ready,
653 /// The service is still generating the statement; poll again.
654 InProgress,
655}
656
657/// The `<FlexStatementResponse>` envelope (SendRequest result and GetStatement "not ready" / error
658/// status). Its fields are child *elements*, not attributes.
659#[derive(Debug, Deserialize)]
660struct FlexStatementResponse {
661 #[serde(rename = "Status", default)]
662 status: String,
663 #[serde(rename = "ReferenceCode", default)]
664 reference_code: Option<String>,
665 #[serde(rename = "Url", default)]
666 url: Option<String>,
667 #[serde(rename = "ErrorCode", default)]
668 error_code: Option<String>,
669 #[serde(rename = "ErrorMessage", default)]
670 error_message: Option<String>,
671}
672
673/// Parse a SendRequest response: success yields the reference code + poll URL; any other status
674/// becomes an [`IbkrFlexError::Flex`].
675fn parse_send_request_response(xml: &str) -> Result<SendRequestOk, IbkrFlexError> {
676 // A SendRequest response is always a `<FlexStatementResponse>` envelope. Reject any other root
677 // element as a parse error up front — otherwise quick_xml's lenient, all-`#[serde(default)]`
678 // deserialization coerces a non-Flex body (e.g. a proxy/CDN HTML error page) into an
679 // empty-status envelope, which would surface as a confusing `Flex { code: "" }`. Mirrors the
680 // root-element check `classify_get_statement` already does, and lets `interpret_send_response`
681 // distinguish a non-Flex body (→ `HttpStatus` under a non-2xx status) from a genuine Flex error.
682 match root_element_name(xml).as_deref() {
683 Some("FlexStatementResponse") => {}
684 Some(other) => {
685 return Err(IbkrFlexError::Parse(format!(
686 "unexpected Flex SendRequest root element <{other}>"
687 )));
688 }
689 None => {
690 return Err(IbkrFlexError::Parse(
691 "empty or unreadable Flex SendRequest response".to_owned(),
692 ));
693 }
694 }
695
696 let resp: FlexStatementResponse = quick_xml::de::from_str(xml).map_err(|e| {
697 IbkrFlexError::Parse(format!("failed to parse Flex SendRequest response: {e}"))
698 })?;
699
700 if resp.status.eq_ignore_ascii_case("Success") {
701 match (nonempty(resp.reference_code), nonempty(resp.url)) {
702 (Some(reference_code), Some(url)) => {
703 // The poll URL is broker-supplied and is fetched next with the Flex token in its
704 // `t=` query parameter. Refuse a non-HTTPS URL so the credential can never be sent
705 // over an unencrypted connection (a scheme check only — no brittle host allowlist).
706 if !url.starts_with("https://") {
707 return Err(IbkrFlexError::Parse(format!(
708 "Flex SendRequest returned a non-HTTPS poll URL; refusing to send credentials over it: {url}"
709 )));
710 }
711 Ok(SendRequestOk {
712 reference_code,
713 url,
714 })
715 }
716 _ => Err(IbkrFlexError::Parse(
717 "Flex SendRequest reported success but is missing ReferenceCode/Url".to_owned(),
718 )),
719 }
720 } else {
721 Err(flex_error(resp, "SendRequest"))
722 }
723}
724
725/// Classify a GetStatement poll response by its root element: a `<FlexQueryResponse>` is the ready
726/// statement; a `<FlexStatementResponse>` is either "still generating" (`ErrorCode 1019`) or a
727/// terminal error.
728fn classify_get_statement(xml: &str) -> Result<GetStatementOutcome, IbkrFlexError> {
729 match root_element_name(xml).as_deref() {
730 Some("FlexQueryResponse") => Ok(GetStatementOutcome::Ready),
731 Some("FlexStatementResponse") => {
732 let resp: FlexStatementResponse = quick_xml::de::from_str(xml).map_err(|e| {
733 IbkrFlexError::Parse(format!(
734 "failed to parse Flex GetStatement status response: {e}"
735 ))
736 })?;
737 // "Generation in progress" always arrives as ErrorCode 1019 *with* Status=Warn per the
738 // IBKR Flex v3 protocol (see the `GET_IN_PROGRESS` fixture). Require both: a 1019 under
739 // Status=Fail would be a terminal error IBKR mislabeled, and retrying it would only burn
740 // the poll budget before timing out. The status compare is case-insensitive to tolerate
741 // label-casing variance.
742 if resp.error_code.as_deref() == Some(GENERATION_IN_PROGRESS_CODE)
743 && resp.status.eq_ignore_ascii_case("warn")
744 {
745 Ok(GetStatementOutcome::InProgress)
746 } else {
747 Err(flex_error(resp, "GetStatement"))
748 }
749 }
750 Some(other) => Err(IbkrFlexError::Parse(format!(
751 "unexpected Flex GetStatement root element <{other}>"
752 ))),
753 None => Err(IbkrFlexError::Parse(
754 "empty or unreadable Flex GetStatement response".to_owned(),
755 )),
756 }
757}
758
759/// Build an [`IbkrFlexError::Flex`] from a non-success status envelope.
760fn flex_error(resp: FlexStatementResponse, stage: &str) -> IbkrFlexError {
761 IbkrFlexError::Flex {
762 code: resp.error_code.unwrap_or_default(),
763 message: resp
764 .error_message
765 .unwrap_or_else(|| format!("Flex {stage} returned non-success status {}", resp.status)),
766 }
767}
768
769/// Scrub the Flex `token` from, and bound, a terminal [`IbkrFlexError::Flex`]'s fields.
770///
771/// A `Flex` error's `message` is the `<ErrorMessage>` element lifted verbatim from a server-supplied
772/// body — the same untrusted source [`IbkrFlexError::HttpStatus`] and [`IbkrFlexError::Parse`]
773/// scrub. A body that reflected the request line (a misconfigured proxy echoing `?t=<TOKEN>` into
774/// its `<ErrorMessage>`) could otherwise carry the credential into the stored error, and an
775/// oversized element could bloat it. `code` is a short structured field in practice, but running it
776/// through the same [`sanitize_error_body`] (redact-before-bound, capped at [`MAX_ERROR_BODY_BYTES`])
777/// costs nothing on this error path and closes the gap uniformly — the scrub is a no-op for a real
778/// numeric Flex code that does not contain the token.
779fn scrub_flex_error(code: String, message: String, token: &str) -> IbkrFlexError {
780 IbkrFlexError::Flex {
781 code: sanitize_error_body(&code, token),
782 message: sanitize_error_body(&message, token),
783 }
784}
785
786/// Interpret a SendRequest response given its HTTP `status` and `body`.
787///
788/// The body is parsed as a Flex envelope **first**, so an IBKR application error that arrives under
789/// a non-2xx status still surfaces as the richer [`IbkrFlexError::Flex`] (e.g. `1003` invalid
790/// token). A body that is *not* a recognizable Flex envelope under a non-success status becomes
791/// [`IbkrFlexError::HttpStatus`] (a proxy/CDN error page) rather than a misleading XML parse error;
792/// the same malformed body under a 2xx status stays a genuine [`IbkrFlexError::Parse`] — with its
793/// message token-scrubbed, since an XML deserialiser embeds fragments of the offending input. A
794/// genuine Flex error envelope surfaces as [`IbkrFlexError::Flex`], its fields likewise scrubbed and
795/// bounded ([`scrub_flex_error`]), since `<ErrorMessage>` is server-supplied free text.
796fn interpret_send_response(
797 status: reqwest::StatusCode,
798 body: &str,
799 token: &str,
800) -> Result<SendRequestOk, IbkrFlexError> {
801 match parse_send_request_response(body) {
802 Err(IbkrFlexError::Parse(_)) if !status.is_success() => {
803 Err(http_status_error(status, body, token))
804 }
805 Err(IbkrFlexError::Parse(message)) => Err(finish_parse_error(message, Some(token))),
806 Err(IbkrFlexError::Flex { code, message }) => Err(scrub_flex_error(code, message, token)),
807 other => other,
808 }
809}
810
811/// Interpret a GetStatement poll response given its HTTP `status` and `body`.
812///
813/// The body is classified **first**, so a `1019` "still generating" that IBKR returns under a
814/// non-2xx status is still recognized as retryable — previously `error_for_status` aborted the poll
815/// before the body was classified. A body that is not a recognizable Flex envelope under a
816/// non-success status becomes [`IbkrFlexError::HttpStatus`]; a terminal Flex error envelope under a
817/// non-2xx status stays the richer [`IbkrFlexError::Flex`], its fields scrubbed and bounded
818/// ([`scrub_flex_error`]) since `<ErrorMessage>` is server-supplied free text.
819fn interpret_poll_response(
820 status: reqwest::StatusCode,
821 body: &str,
822 token: &str,
823) -> Result<GetStatementOutcome, IbkrFlexError> {
824 match classify_get_statement(body) {
825 Err(IbkrFlexError::Parse(_)) if !status.is_success() => {
826 Err(http_status_error(status, body, token))
827 }
828 Err(IbkrFlexError::Parse(message)) => Err(finish_parse_error(message, Some(token))),
829 Err(IbkrFlexError::Flex { code, message }) => Err(scrub_flex_error(code, message, token)),
830 other => other,
831 }
832}
833
834/// Build an [`IbkrFlexError::Parse`] from an **unbounded** message: redact `token` when one is in
835/// scope, then bound the result to [`MAX_ERROR_BODY_BYTES`].
836///
837/// A parse failure message embeds the deserialiser's own rendering of the offending input —
838/// quick-xml's `DeError::UnexpectedStart`, for instance, carries the raw tag bytes it choked on. A
839/// body that reflected the request line (a misconfigured proxy echoing `?t=<TOKEN>`) could
840/// therefore carry the credential into the stored error, which [`IbkrFlexError::HttpStatus`] is
841/// already protected against but this variant was not.
842///
843/// This is the single **finaliser** for a `Parse` that leaves the module — not the single
844/// construction point. The raw parsers [`parse_send_request_response`] and [`classify_get_statement`]
845/// build *unbounded, unscrubbed* `Parse` values directly, but they are private and every production
846/// caller routes their `Err(Parse(_))` back through here (or, for a non-2xx non-envelope body,
847/// through [`http_status_error`]): see [`interpret_send_response`]/[`interpret_poll_response`], and
848/// [`parse_corporate_actions_scrubbed`](corporate_action::parse_corporate_actions_scrubbed) for the
849/// statement re-parse. Keeping the redact-before-bound ordering in exactly one place is what makes
850/// that safe: it is a security invariant, not an implementation detail — redaction matches the full
851/// token, so bounding first can leave a credential straddling the cap present only as an unmatched
852/// prefix fragment. A finaliser duplicated across callers would be one edit away from diverging on
853/// it.
854///
855/// Maintainer corollary: the guarantee is caller discipline over two private parsers, **not** a
856/// property the type system enforces. A *new* call site for either raw parser must route its `Parse`
857/// through this function (or `http_status_error`) too, or it reintroduces the unbounded/unscrubbed
858/// leak this exists to prevent.
859///
860/// `message` must therefore arrive unbounded — a caller that has already truncated at
861/// [`MAX_ERROR_BODY_BYTES`] has already lost the guarantee, and passing it here cannot recover it.
862/// Both branches end at the same cap and differ only in whether a redaction pass runs first, so the
863/// bound is applied exactly once either way.
864fn finish_parse_error(message: String, token: Option<&str>) -> IbkrFlexError {
865 IbkrFlexError::Parse(match token {
866 Some(token) => sanitize_error_body(&message, token),
867 None => truncate_str(&message, MAX_ERROR_BODY_BYTES),
868 })
869}
870
871/// Build an [`IbkrFlexError::HttpStatus`] from a non-success response, bounding and token-scrubbing
872/// the body.
873fn http_status_error(status: reqwest::StatusCode, body: &str, token: &str) -> IbkrFlexError {
874 IbkrFlexError::HttpStatus {
875 status: status.as_u16(),
876 body: sanitize_error_body(body, token),
877 }
878}
879
880/// Redact the Flex `token` — both its raw form and its `application/x-www-form-urlencoded` wire
881/// form — from `body`, then bound the result to [`MAX_ERROR_BODY_BYTES`] (on a UTF-8 char boundary).
882///
883/// The token rides in the request URL's `t=` query parameter, attached via reqwest's `.query(&[…])`,
884/// which serialises through [`url::Url::query_pairs_mut`] — WHATWG form-urlencoding, which escapes
885/// space as `+` and leaves a *narrower* byte set unescaped than RFC-3986 percent-encoding (e.g. `~`
886/// is escaped here). A misconfigured proxy or WAF that echoes the request line into its error page
887/// would reflect *that* wire form, not the raw token, so both are scrubbed. The wire form is computed
888/// with [`url::form_urlencoded::byte_serialize`] — the same primitive `url`'s serializer (and thus
889/// reqwest's `.query()`) uses internally, not a hand-rolled percent-encoding table — so it cannot
890/// drift from what is actually sent (`sanitize_error_body_scrubs_reqwests_wire_encoded_token` pins
891/// this against reqwest's real request encoding).
892///
893/// This is **best-effort defense-in-depth, not an absolute guarantee**: an intermediary could still
894/// transform the reflected value in a way this scrub does not anticipate (further re-encoding,
895/// HTML-entity escaping, or truncation that splits the token). For a real IBKR token — a numeric
896/// string that encodes to itself — the raw scrub already covers it and the wire-form pass is a no-op.
897///
898/// How `body` is bounded depends on the caller, and only one of the two arrives pre-bounded:
899///
900/// - Via [`http_status_error`] (non-success status), `body` has already passed through
901/// [`read_body_capped`] at [`MAX_ERROR_BODY_DOWNLOAD_BYTES`] (64 KiB) in
902/// [`IbkrFlexClient::get_with_query`]. The two caps are complementary, not redundant — the outer
903/// one bounds memory during the network read itself, this one bounds what is retained in the
904/// stored error. The 64× gap between them also means a token fragment straddling the *download*
905/// boundary cannot survive into the final message: the trailing partial token sits far past
906/// [`MAX_ERROR_BODY_BYTES`] and is truncated away.
907/// - Via [`finish_parse_error`] (2xx status), `body` is a deserialiser message — raised either
908/// while interpreting a SendRequest/GetStatement response or while re-parsing a statement in
909/// [`parse_corporate_actions_scrubbed`](corporate_action::parse_corporate_actions_scrubbed) —
910/// derived from an **unbounded** `response.text()` read,
911/// so no download-level cap applies to it. This function therefore applies the coarse
912/// [`MAX_ERROR_BODY_DOWNLOAD_BYTES`] bound itself, on entry, reproducing the same 64× gap the
913/// non-success path gets for free — wide enough that it cannot split a token whose fragment would
914/// then survive into the retained message, but narrow enough that the redaction passes below do
915/// not scan an unbounded string.
916///
917/// Callers must pass a message that has **not** already been truncated at
918/// [`MAX_ERROR_BODY_BYTES`]: redaction matches the full token, so a caller-side cut at the same
919/// width this function retains could leave a straddling credential as an unmatched prefix. Both 2xx
920/// callers hand over their message unbounded for exactly this reason.
921fn sanitize_error_body(body: &str, token: &str) -> String {
922 // Bound the input before the redaction passes. `redact` allocates a full copy per pass even when
923 // the needle is absent, and a 2xx `Parse` message derives from an unbounded read, so without
924 // this an oversized message is copied twice before the trailing truncate discards nearly all of
925 // it. Slicing (rather than `truncate_str`) keeps the common under-cap case allocation-free, and
926 // the cap sits 64× above what is retained, so it cannot create the straddling-fragment problem
927 // the redact-then-bound ordering below exists to avoid.
928 let body = if body.len() > MAX_ERROR_BODY_DOWNLOAD_BYTES {
929 &body[..body.floor_char_boundary(MAX_ERROR_BODY_DOWNLOAD_BYTES)]
930 } else {
931 body
932 };
933 // Redact the token over the (coarsely bounded) body first — order matters for correctness, not
934 // just cost:
935 // bounding first could leave a credential that straddles the byte cap present only as a prefix,
936 // which the full-token `contains`/`replace` would then miss, and redacting after bounding could
937 // also push the result back over the cap (`[REDACTED]` is longer than a short token). Scanning
938 // the whole body only runs on the error path, so the extra pass is acceptable.
939 let mut scrubbed = redact(body, token);
940 // Also scrub the encoded form the token actually takes on the wire, so a reflected request line
941 // is covered too — but only when it differs from the raw token (a no-op for numeric tokens).
942 if !token.is_empty() {
943 let wire_form: String = url::form_urlencoded::byte_serialize(token.as_bytes()).collect();
944 if wire_form.as_str() != token {
945 scrubbed = redact(&scrubbed, &wire_form);
946 }
947 }
948 // Then bound to MAX_ERROR_BODY_BYTES so a large proxy/CDN error page can't bloat the error.
949 // `floor_char_boundary` rounds the cap down to a UTF-8 char boundary (there is always one at or
950 // below it), keeping the truncated string valid — which `String::truncate` requires. Matches the
951 // idiom used across the crate (e.g. `massive::error`, `massive::rest`, `binance::error`).
952 if scrubbed.len() > MAX_ERROR_BODY_BYTES {
953 scrubbed.truncate(scrubbed.floor_char_boundary(MAX_ERROR_BODY_BYTES));
954 }
955 scrubbed
956}
957
958/// Replace every occurrence of `needle` in `body` with `[REDACTED]`, always returning an owned
959/// `String` (the caller chains two redaction passes over the result, so a borrowing return would not
960/// help). The `contains` guard skips only the `replace` scan when `needle` is absent — it does not
961/// avoid the allocation. An empty `needle` is never treated as a match: `str::replace` with an empty
962/// pattern would splice the replacement between every character.
963fn redact(body: &str, needle: &str) -> String {
964 if !needle.is_empty() && body.contains(needle) {
965 body.replace(needle, "[REDACTED]")
966 } else {
967 body.to_owned()
968 }
969}
970
971/// Read the name of the first (root) element of an XML document, ignoring the prolog/comments.
972///
973/// Returns a [`SmolStr`] so the expected root names (`FlexQueryResponse`, `FlexStatementResponse`)
974/// are compared without a heap allocation (both fit `SmolStr`'s inline buffer).
975fn root_element_name(xml: &str) -> Option<SmolStr> {
976 let mut reader = Reader::from_str(xml);
977 loop {
978 match reader.read_event() {
979 Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
980 return Some(SmolStr::new(e.name().as_ref()));
981 }
982 Ok(Event::Eof) | Err(_) => return None,
983 Ok(_) => {}
984 }
985 }
986}
987
988/// Trim an optional string, collapsing empty/whitespace-only values to `None`.
989///
990/// Reuses the original allocation when the value needs no trimming (the common case for
991/// well-formed Flex attributes), only allocating when whitespace is actually stripped.
992fn nonempty(value: Option<String>) -> Option<String> {
993 value.and_then(|v| {
994 let trimmed = v.trim();
995 if trimmed.is_empty() {
996 None
997 } else if trimmed.len() == v.len() {
998 Some(v)
999 } else {
1000 Some(trimmed.to_owned())
1001 }
1002 })
1003}
1004
1005#[cfg(test)]
1006#[allow(clippy::unwrap_used, clippy::expect_used)] // Tests should panic on unexpected values.
1007mod tests {
1008 use super::*;
1009
1010 const SEND_SUCCESS: &str = r#"<FlexStatementResponse timestamp="15 January, 2025 03:00 PM EST">
1011 <Status>Success</Status>
1012 <ReferenceCode>1234567890</ReferenceCode>
1013 <Url>https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement</Url>
1014 </FlexStatementResponse>"#;
1015
1016 const SEND_FAIL: &str = r#"<FlexStatementResponse timestamp="15 January, 2025 03:00 PM EST">
1017 <Status>Fail</Status>
1018 <ErrorCode>1003</ErrorCode>
1019 <ErrorMessage>Statement could not be generated at this time. Please try again shortly.</ErrorMessage>
1020 </FlexStatementResponse>"#;
1021
1022 const GET_IN_PROGRESS: &str = r#"<FlexStatementResponse timestamp="15 January, 2025 03:00 PM EST">
1023 <Status>Warn</Status>
1024 <ErrorCode>1019</ErrorCode>
1025 <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1026 </FlexStatementResponse>"#;
1027
1028 const GET_FAIL: &str = r#"<FlexStatementResponse>
1029 <Status>Fail</Status>
1030 <ErrorCode>1020</ErrorCode>
1031 <ErrorMessage>Invalid request or unable to validate request.</ErrorMessage>
1032 </FlexStatementResponse>"#;
1033
1034 const GET_READY: &str = r#"<?xml version="1.0"?>
1035 <FlexQueryResponse queryName="Activity" type="AF">
1036 <FlexStatements count="1">
1037 <FlexStatement accountId="U1"><CorporateActions /></FlexStatement>
1038 </FlexStatements>
1039 </FlexQueryResponse>"#;
1040
1041 #[test]
1042 fn send_request_success_extracts_reference_and_url() {
1043 let ok = parse_send_request_response(SEND_SUCCESS).unwrap();
1044 assert_eq!(ok.reference_code, "1234567890");
1045 assert_eq!(
1046 ok.url,
1047 "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement"
1048 );
1049 }
1050
1051 #[test]
1052 fn send_request_failure_is_a_flex_error() {
1053 match parse_send_request_response(SEND_FAIL) {
1054 Err(IbkrFlexError::Flex { code, message }) => {
1055 assert_eq!(code, "1003");
1056 assert!(message.contains("could not be generated"));
1057 }
1058 other => panic!("expected Flex error, got {other:?}"),
1059 }
1060 }
1061
1062 #[test]
1063 fn send_request_success_without_url_is_a_parse_error() {
1064 let xml = r#"<FlexStatementResponse><Status>Success</Status>
1065 <ReferenceCode>123</ReferenceCode></FlexStatementResponse>"#;
1066 assert!(matches!(
1067 parse_send_request_response(xml),
1068 Err(IbkrFlexError::Parse(_))
1069 ));
1070 }
1071
1072 #[test]
1073 fn send_request_non_https_url_is_rejected() {
1074 // A success response whose poll URL is plain HTTP must be refused: the token rides in that
1075 // URL's `t=` query parameter and must never traverse an unencrypted connection.
1076 let xml = r#"<FlexStatementResponse><Status>Success</Status>
1077 <ReferenceCode>123</ReferenceCode>
1078 <Url>http://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement</Url></FlexStatementResponse>"#;
1079 assert!(matches!(
1080 parse_send_request_response(xml),
1081 Err(IbkrFlexError::Parse(_))
1082 ));
1083 }
1084
1085 #[test]
1086 fn send_request_non_envelope_root_is_a_parse_error() {
1087 // A non-<FlexStatementResponse> body (e.g. a proxy/CDN HTML error page) must be a parse
1088 // error, not coerced by lenient all-`#[serde(default)]` deserialization into a confusing
1089 // empty-status `Flex` error. This is what lets `interpret_send_response` treat it as an
1090 // HttpStatus under a non-2xx status.
1091 for body in [
1092 "<html><body>502 Bad Gateway</body></html>",
1093 "<not-flex/>",
1094 "",
1095 ] {
1096 assert!(
1097 matches!(
1098 parse_send_request_response(body),
1099 Err(IbkrFlexError::Parse(_))
1100 ),
1101 "expected Parse for body {body:?}"
1102 );
1103 }
1104 }
1105
1106 #[test]
1107 fn get_statement_ready_is_classified_ready() {
1108 assert_eq!(
1109 classify_get_statement(GET_READY).unwrap(),
1110 GetStatementOutcome::Ready
1111 );
1112 }
1113
1114 #[test]
1115 fn get_statement_generation_in_progress_is_classified_in_progress() {
1116 assert_eq!(
1117 classify_get_statement(GET_IN_PROGRESS).unwrap(),
1118 GetStatementOutcome::InProgress
1119 );
1120 }
1121
1122 #[test]
1123 fn get_statement_in_progress_code_is_case_insensitive_on_status() {
1124 // The `Status=Warn` check uses `eq_ignore_ascii_case`, so casing variance (e.g. all-caps
1125 // `WARN`) must still classify a 1019 as retryable rather than fail fast on a terminal error.
1126 const WARN_UPPER_1019: &str = r#"<FlexStatementResponse>
1127 <Status>WARN</Status>
1128 <ErrorCode>1019</ErrorCode>
1129 <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1130 </FlexStatementResponse>"#;
1131 assert_eq!(
1132 classify_get_statement(WARN_UPPER_1019).unwrap(),
1133 GetStatementOutcome::InProgress
1134 );
1135 }
1136
1137 #[test]
1138 fn get_statement_in_progress_code_under_fail_status_is_a_flex_error() {
1139 // ErrorCode 1019 is only retryable under Status=Warn. If IBKR ever returns 1019 under
1140 // Status=Fail, treat it as a terminal error instead of retrying a failure until the poll
1141 // budget is exhausted.
1142 const FAIL_1019: &str = r#"<FlexStatementResponse>
1143 <Status>Fail</Status>
1144 <ErrorCode>1019</ErrorCode>
1145 <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1146 </FlexStatementResponse>"#;
1147 match classify_get_statement(FAIL_1019) {
1148 Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1019"),
1149 other => panic!("expected terminal Flex error, got {other:?}"),
1150 }
1151 }
1152
1153 #[test]
1154 fn get_statement_1019_without_status_is_a_flex_error() {
1155 // `Status` deserializes via `#[serde(default)]`, so a 1019 envelope with no `<Status>`
1156 // element yields `status == ""`. Retryability requires Status=Warn, so a missing status is
1157 // treated as terminal rather than retried until the poll budget is exhausted.
1158 const NO_STATUS_1019: &str = r#"<FlexStatementResponse>
1159 <ErrorCode>1019</ErrorCode>
1160 <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1161 </FlexStatementResponse>"#;
1162 match classify_get_statement(NO_STATUS_1019) {
1163 Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1019"),
1164 other => panic!("expected terminal Flex error, got {other:?}"),
1165 }
1166 }
1167
1168 #[test]
1169 fn get_statement_1019_under_success_status_is_a_flex_error() {
1170 // 1019 is only retryable under Status=Warn. A 1019 reported under Status=Success is a
1171 // self-contradictory envelope; treat it as terminal rather than retrying it until the poll
1172 // budget is exhausted.
1173 const SUCCESS_1019: &str = r#"<FlexStatementResponse>
1174 <Status>Success</Status>
1175 <ErrorCode>1019</ErrorCode>
1176 <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1177 </FlexStatementResponse>"#;
1178 match classify_get_statement(SUCCESS_1019) {
1179 Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1019"),
1180 other => panic!("expected terminal Flex error, got {other:?}"),
1181 }
1182 }
1183
1184 #[test]
1185 fn get_statement_terminal_failure_is_a_flex_error() {
1186 match classify_get_statement(GET_FAIL) {
1187 Err(IbkrFlexError::Flex { code, message }) => {
1188 assert_eq!(code, "1020");
1189 assert!(message.contains("Invalid request"));
1190 }
1191 other => panic!("expected Flex error, got {other:?}"),
1192 }
1193 }
1194
1195 #[test]
1196 fn root_element_name_skips_prolog() {
1197 assert_eq!(
1198 root_element_name(GET_READY).as_deref(),
1199 Some("FlexQueryResponse")
1200 );
1201 assert_eq!(
1202 root_element_name(SEND_SUCCESS).as_deref(),
1203 Some("FlexStatementResponse")
1204 );
1205 assert_eq!(root_element_name("").as_deref(), None);
1206 }
1207
1208 #[test]
1209 fn config_debug_redacts_token() {
1210 let config =
1211 IbkrFlexConfig::new("super-secret-token", "987654").expect("valid credentials");
1212 let debug = format!("{config:?}");
1213 assert!(
1214 !debug.contains("super-secret-token"),
1215 "token must be redacted"
1216 );
1217 assert!(debug.contains("987654"), "query_id is not a secret");
1218 }
1219
1220 #[test]
1221 fn client_debug_hides_token() {
1222 let client = IbkrFlexClient::new(
1223 IbkrFlexConfig::new("super-secret-token", "987654").expect("valid credentials"),
1224 )
1225 .expect("HTTP client should build");
1226 let debug = format!("{client:?}");
1227 assert!(
1228 !debug.contains("super-secret-token"),
1229 "token must not leak via Debug"
1230 );
1231 assert!(debug.contains("987654"));
1232 }
1233
1234 #[test]
1235 fn new_rejects_empty_or_whitespace_token() {
1236 for token in ["", " ", "\t\n"] {
1237 match IbkrFlexConfig::new(token, "987654") {
1238 Err(IbkrFlexError::InvalidCredential(msg)) => assert!(
1239 msg.contains("token"),
1240 "error should name the `token` field, got: {msg}"
1241 ),
1242 other => panic!("expected InvalidCredential for token {token:?}, got: {other:?}"),
1243 }
1244 }
1245 }
1246
1247 #[test]
1248 fn new_rejects_empty_or_whitespace_query_id() {
1249 for query_id in ["", " ", "\t\n"] {
1250 match IbkrFlexConfig::new("valid-token", query_id) {
1251 Err(IbkrFlexError::InvalidCredential(msg)) => assert!(
1252 msg.contains("query_id"),
1253 "error should name the `query_id` field, got: {msg}"
1254 ),
1255 other => {
1256 panic!("expected InvalidCredential for query_id {query_id:?}, got: {other:?}")
1257 }
1258 }
1259 }
1260 }
1261
1262 #[test]
1263 fn new_trims_surrounding_whitespace() {
1264 let config = IbkrFlexConfig::new(" super-secret-token ", " 987654 ")
1265 .expect("padded-but-nonempty credentials are valid");
1266 // Debug redacts the token, so `query_id` is the only observable window into the stored
1267 // (trimmed) values. Confirm the surrounding whitespace was stripped before storage.
1268 let debug = format!("{config:?}");
1269 assert!(
1270 debug.contains("\"987654\""),
1271 "query_id should be trimmed to `987654`, got: {debug}"
1272 );
1273 assert!(
1274 !debug.contains(" 987654"),
1275 "surrounding whitespace should not survive into the stored query_id, got: {debug}"
1276 );
1277 }
1278
1279 // ----- status/body interpretation (part (a): read body before branching on status) -----
1280
1281 #[test]
1282 fn interpret_send_response_success_parses() {
1283 let ok = interpret_send_response(reqwest::StatusCode::OK, SEND_SUCCESS, "tok").unwrap();
1284 assert_eq!(ok.reference_code, "1234567890");
1285 }
1286
1287 #[test]
1288 fn interpret_send_response_flex_error_preserved_under_non_2xx() {
1289 // An IBKR application error (Fail/1003) that arrives under a non-success HTTP status must
1290 // still surface as the richer Flex error, not be masked as a raw transport status.
1291 match interpret_send_response(reqwest::StatusCode::UNAUTHORIZED, SEND_FAIL, "tok") {
1292 Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1003"),
1293 other => panic!("expected Flex error, got {other:?}"),
1294 }
1295 }
1296
1297 #[test]
1298 fn interpret_send_response_non_flex_body_under_non_2xx_is_http_status() {
1299 // A proxy/CDN error page (not a Flex envelope) under a non-2xx status becomes HttpStatus,
1300 // carrying the status + body rather than being discarded (as `error_for_status` did) or
1301 // surfacing as a misleading XML parse error.
1302 let body = "<html><body>502 Bad Gateway</body></html>";
1303 match interpret_send_response(reqwest::StatusCode::BAD_GATEWAY, body, "tok") {
1304 Err(IbkrFlexError::HttpStatus { status, body }) => {
1305 assert_eq!(status, 502);
1306 assert!(body.contains("Bad Gateway"), "diagnostic body preserved");
1307 }
1308 other => panic!("expected HttpStatus, got {other:?}"),
1309 }
1310 }
1311
1312 #[test]
1313 fn interpret_send_response_garbage_under_2xx_is_parse_error() {
1314 // A malformed body under a *success* status is a genuine parse failure, not a transport
1315 // status — the HttpStatus fallback must fire only on non-success statuses.
1316 assert!(matches!(
1317 interpret_send_response(reqwest::StatusCode::OK, "<not-flex/>", "tok"),
1318 Err(IbkrFlexError::Parse(_))
1319 ));
1320 }
1321
1322 #[test]
1323 fn interpret_poll_response_ready_and_in_progress() {
1324 assert_eq!(
1325 interpret_poll_response(reqwest::StatusCode::OK, GET_READY, "tok").unwrap(),
1326 GetStatementOutcome::Ready
1327 );
1328 assert_eq!(
1329 interpret_poll_response(reqwest::StatusCode::OK, GET_IN_PROGRESS, "tok").unwrap(),
1330 GetStatementOutcome::InProgress
1331 );
1332 }
1333
1334 #[test]
1335 fn interpret_poll_response_1019_under_non_2xx_is_still_retryable() {
1336 // R9 retry-path fix: a 1019 "still generating" that IBKR returns under a non-success HTTP
1337 // status must remain retryable. Previously `error_for_status` aborted the poll before the
1338 // body was ever classified, turning a transient generation delay into a hard failure.
1339 assert_eq!(
1340 interpret_poll_response(
1341 reqwest::StatusCode::SERVICE_UNAVAILABLE,
1342 GET_IN_PROGRESS,
1343 "tok"
1344 )
1345 .unwrap(),
1346 GetStatementOutcome::InProgress
1347 );
1348 }
1349
1350 #[test]
1351 fn interpret_poll_response_terminal_flex_error_preserved_under_non_2xx() {
1352 match interpret_poll_response(reqwest::StatusCode::BAD_REQUEST, GET_FAIL, "tok") {
1353 Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1020"),
1354 other => panic!("expected Flex error, got {other:?}"),
1355 }
1356 }
1357
1358 #[test]
1359 fn interpret_send_response_flex_error_message_is_token_scrubbed() {
1360 // A genuine Flex error envelope's <ErrorMessage> is server-supplied free text — a
1361 // misconfigured proxy could reflect the request line (with the `t=` token) into it. The
1362 // stored Flex error must never carry the credential, the same protection HttpStatus/Parse
1363 // already have. The Flex code is still preserved so consumers can match on it.
1364 let token = "super-secret-token";
1365 let body = format!(
1366 "<FlexStatementResponse><Status>Fail</Status><ErrorCode>1003</ErrorCode>\
1367 <ErrorMessage>invalid token for GET /SendRequest?t={token}&q=1&v=3</ErrorMessage>\
1368 </FlexStatementResponse>"
1369 );
1370 match interpret_send_response(reqwest::StatusCode::OK, &body, token) {
1371 Err(IbkrFlexError::Flex { code, message }) => {
1372 assert_eq!(code, "1003", "the Flex code is preserved for matching");
1373 assert!(
1374 !message.contains(token),
1375 "token must be redacted, got: {message}"
1376 );
1377 assert!(message.contains("[REDACTED]"));
1378 }
1379 other => panic!("expected Flex, got {other:?}"),
1380 }
1381 }
1382
1383 #[test]
1384 fn interpret_poll_response_flex_error_message_is_token_scrubbed() {
1385 // Sibling of the SendRequest case above, for the GetStatement poll path.
1386 let token = "super-secret-token";
1387 let body = format!(
1388 "<FlexStatementResponse><Status>Fail</Status><ErrorCode>1020</ErrorCode>\
1389 <ErrorMessage>bad reference for GET /GetStatement?t={token}&q=1&v=3</ErrorMessage>\
1390 </FlexStatementResponse>"
1391 );
1392 match interpret_poll_response(reqwest::StatusCode::OK, &body, token) {
1393 Err(IbkrFlexError::Flex { code, message }) => {
1394 assert_eq!(code, "1020");
1395 assert!(
1396 !message.contains(token),
1397 "token must be redacted, got: {message}"
1398 );
1399 assert!(message.contains("[REDACTED]"));
1400 }
1401 other => panic!("expected Flex, got {other:?}"),
1402 }
1403 }
1404
1405 #[test]
1406 fn interpret_send_response_flex_error_code_is_token_scrubbed() {
1407 // `scrub_flex_error` runs `code` through the same redact-before-bound path as `message`, so a
1408 // token reflected into a `<ErrorCode>` (a no-op for the real numeric codes IBKR returns, but
1409 // the field is still server-supplied) must not survive into the stored error either.
1410 let token = "super-secret-token";
1411 let body = format!(
1412 "<FlexStatementResponse><Status>Fail</Status>\
1413 <ErrorCode>bogus t={token}</ErrorCode>\
1414 <ErrorMessage>rejected</ErrorMessage></FlexStatementResponse>"
1415 );
1416 match interpret_send_response(reqwest::StatusCode::OK, &body, token) {
1417 Err(IbkrFlexError::Flex { code, message }) => {
1418 assert!(
1419 !code.contains(token),
1420 "token must be redacted from code, got: {code}"
1421 );
1422 assert!(code.contains("[REDACTED]"));
1423 assert_eq!(message, "rejected");
1424 }
1425 other => panic!("expected Flex, got {other:?}"),
1426 }
1427 }
1428
1429 #[test]
1430 fn flex_error_message_is_bounded() {
1431 // A `Flex` message derives from an unbounded 2xx `response.text()` read, so an adversarial or
1432 // buggy server returning a well-formed Flex error envelope with a huge <ErrorMessage> must be
1433 // bounded like HttpStatus/Parse, not stored whole. Multi-byte chars confirm the cap lands on
1434 // a char boundary rather than panicking.
1435 let body = format!(
1436 "<FlexStatementResponse><Status>Fail</Status><ErrorCode>1003</ErrorCode>\
1437 <ErrorMessage>{}</ErrorMessage></FlexStatementResponse>",
1438 "€".repeat(1000) // 3000 bytes, exceeds the 1024-byte cap
1439 );
1440 match interpret_send_response(reqwest::StatusCode::OK, &body, "tok") {
1441 Err(IbkrFlexError::Flex { message, .. }) => assert!(
1442 message.len() <= MAX_ERROR_BODY_BYTES,
1443 "message bounded to the cap, got {} bytes",
1444 message.len()
1445 ),
1446 other => panic!("expected Flex, got {other:?}"),
1447 }
1448 }
1449
1450 #[test]
1451 fn interpret_poll_response_non_flex_body_under_non_2xx_is_http_status() {
1452 match interpret_poll_response(
1453 reqwest::StatusCode::GATEWAY_TIMEOUT,
1454 "upstream request timed out",
1455 "tok",
1456 ) {
1457 Err(IbkrFlexError::HttpStatus { status, body }) => {
1458 assert_eq!(status, 504);
1459 assert!(body.contains("timed out"));
1460 }
1461 other => panic!("expected HttpStatus, got {other:?}"),
1462 }
1463 }
1464
1465 /// Build a `reqwest::Response` directly, with no socket and no mock server, via reqwest's
1466 /// `From<http::Response<_>>` impl. This is what makes the transport-layer read testable without
1467 /// relaxing the client's `https_only(true)` guard — the Flex token rides in the request URL, so
1468 /// a plaintext test server would mean building a client that is not the production one.
1469 fn synthetic_response(status: u16, body: &'static str) -> reqwest::Response {
1470 reqwest::Response::from(
1471 http::Response::builder()
1472 .status(status)
1473 .body(body)
1474 .expect("status and body are valid"),
1475 )
1476 }
1477
1478 #[tokio::test]
1479 async fn read_status_and_body_keeps_a_1019_body_under_a_non_2xx_status() {
1480 // The regression this pins, and the one no `interpret_*` test can catch: reverting
1481 // `get_with_query` to `error_for_status()?.text().await?` discards the body on a non-2xx,
1482 // so the retryable 1019 envelope never reaches `interpret_poll_response` and the poll
1483 // aborts instead of retrying. Here the body must survive the non-success status intact.
1484 let (status, body) = read_status_and_body(synthetic_response(503, GET_IN_PROGRESS))
1485 .await
1486 .expect("a non-success status must still yield its body");
1487
1488 assert_eq!(status, reqwest::StatusCode::SERVICE_UNAVAILABLE);
1489 assert_eq!(
1490 classify_get_statement(&body).expect("body is a Flex envelope"),
1491 GetStatementOutcome::InProgress,
1492 "a 1019 arriving under a non-2xx status must still classify as retryable"
1493 );
1494 }
1495
1496 #[tokio::test]
1497 async fn read_status_and_body_reads_a_success_body_in_full() {
1498 // The success path is the statement payload and must not be routed through the error-path
1499 // cap, which would silently truncate a large `<FlexQueryResponse>`.
1500 let (status, body) = read_status_and_body(synthetic_response(200, GET_READY))
1501 .await
1502 .expect("success body reads");
1503
1504 assert_eq!(status, reqwest::StatusCode::OK);
1505 assert_eq!(body, GET_READY);
1506 }
1507
1508 #[test]
1509 fn http_status_body_is_token_scrubbed() {
1510 // A misconfigured proxy could reflect the request URL (with the `t=` token) into its error
1511 // page; the stored HttpStatus body must never carry the credential.
1512 let token = "super-secret-token";
1513 let body = format!("400 Bad Request: GET /SendRequest?t={token}&q=1&v=3");
1514 match interpret_send_response(reqwest::StatusCode::BAD_REQUEST, &body, token) {
1515 Err(IbkrFlexError::HttpStatus { body, .. }) => {
1516 assert!(!body.contains(token), "token must be redacted, got: {body}");
1517 assert!(body.contains("[REDACTED]"));
1518 }
1519 other => panic!("expected HttpStatus, got {other:?}"),
1520 }
1521 }
1522
1523 #[test]
1524 fn parse_message_is_token_scrubbed_under_2xx() {
1525 // The `HttpStatus` path is covered above; this pins the sibling `Parse` path, which is only
1526 // reachable under a *success* status and so never passes through `http_status_error`. A
1527 // deserialiser message embeds fragments of the offending input, so a body that reflected the
1528 // request line must not carry the credential into the stored error either. Both interpreters
1529 // route their `Parse` arm through `finish_parse_error`; assert both.
1530 let token = "super-secret-token";
1531 // The root-element check formats the tag name verbatim into the message, so naming the root
1532 // after the token is the shortest body that reflects it into a `Parse`.
1533 let body = format!("<{token}/>");
1534
1535 match interpret_send_response(reqwest::StatusCode::OK, &body, token) {
1536 Err(IbkrFlexError::Parse(message)) => {
1537 assert!(
1538 !message.contains(token),
1539 "token must be redacted, got: {message}"
1540 );
1541 assert!(message.contains("[REDACTED]"));
1542 }
1543 other => panic!("expected Parse, got {other:?}"),
1544 }
1545
1546 match interpret_poll_response(reqwest::StatusCode::OK, &body, token) {
1547 Err(IbkrFlexError::Parse(message)) => {
1548 assert!(
1549 !message.contains(token),
1550 "token must be redacted, got: {message}"
1551 );
1552 assert!(message.contains("[REDACTED]"));
1553 }
1554 other => panic!("expected Parse, got {other:?}"),
1555 }
1556 }
1557
1558 #[test]
1559 fn parse_message_is_bounded_under_2xx() {
1560 // `Parse` messages derive from an unbounded 2xx `response.text()` read, so — unlike the
1561 // `HttpStatus` path — `sanitize_error_body`'s trailing truncate is the *only* thing bounding
1562 // them. Multi-byte chars confirm the cap lands on a char boundary rather than panicking.
1563 let body = format!("<{}/>", "€".repeat(1000)); // 3000 bytes of tag name, exceeds the cap
1564 match interpret_send_response(reqwest::StatusCode::OK, &body, "tok") {
1565 Err(IbkrFlexError::Parse(message)) => {
1566 // Guard against a vacuous pass: the short "empty or unreadable" fallback is under
1567 // the cap regardless, so confirm this really is the input-reflecting message.
1568 assert!(
1569 message.starts_with("unexpected"),
1570 "fixture must reach the root-element path, got: {message}"
1571 );
1572 assert!(
1573 message.len() <= MAX_ERROR_BODY_BYTES,
1574 "message bounded to the cap, got {} bytes",
1575 message.len()
1576 );
1577 }
1578 other => panic!("expected Parse, got {other:?}"),
1579 }
1580 }
1581
1582 #[test]
1583 fn http_status_body_is_bounded_on_char_boundary() {
1584 // A large body is truncated to the cap without splitting a multi-byte char (`€` is 3 bytes,
1585 // 1024 is not a multiple of 3, so a naive byte-slice would panic on an invalid boundary).
1586 let body = "€".repeat(1000); // 3000 bytes, exceeds the 1024-byte cap
1587 match interpret_send_response(reqwest::StatusCode::BAD_GATEWAY, &body, "tok") {
1588 Err(IbkrFlexError::HttpStatus { body, .. }) => {
1589 assert!(
1590 body.len() <= MAX_ERROR_BODY_BYTES,
1591 "body bounded to the cap"
1592 );
1593 assert!(
1594 body.chars().all(|c| c == '€'),
1595 "truncation must land on a char boundary (valid UTF-8)"
1596 );
1597 }
1598 other => panic!("expected HttpStatus, got {other:?}"),
1599 }
1600 }
1601
1602 #[test]
1603 fn http_status_body_scrubs_token_straddling_the_cap() {
1604 // Regression: the token must be redacted even when it straddles the MAX_ERROR_BODY_BYTES cap
1605 // in the *original* body. Redacting after bounding (the previous order) would leave only a
1606 // prefix of the token in the bounded slice, which the full-token match then misses — leaking
1607 // the fragment. Redacting first also keeps the result within the cap (`[REDACTED]` is longer
1608 // than a short token, so replacing after bounding could overrun it).
1609 let token = "SECRETTOKEN1234567890"; // 21 bytes
1610 let prefix = "x".repeat(MAX_ERROR_BODY_BYTES - 9); // token starts 9 bytes before the cap
1611 let body = format!("{prefix}{token}{}", "y".repeat(100)); // > cap; token spans the boundary
1612 match interpret_send_response(reqwest::StatusCode::BAD_REQUEST, &body, token) {
1613 Err(IbkrFlexError::HttpStatus { body, .. }) => {
1614 assert!(
1615 !body.contains(token),
1616 "full token must be redacted, got: {body}"
1617 );
1618 assert!(
1619 !body.contains("SECRET"),
1620 "no token fragment may survive the boundary, got: {body}"
1621 );
1622 assert!(
1623 body.len() <= MAX_ERROR_BODY_BYTES,
1624 "redaction must not push the body back over the cap, len = {}",
1625 body.len()
1626 );
1627 }
1628 other => panic!("expected HttpStatus, got {other:?}"),
1629 }
1630 }
1631
1632 #[test]
1633 fn corporate_action_parse_scrubs_token_straddling_the_cap() {
1634 // Sibling of `http_status_body_scrubs_token_straddling_the_cap`, for the path that reaches
1635 // `Parse` through `fetch_corporate_actions`. Regression: `parse_corporate_actions` bounds its
1636 // own message, so scrubbing its *result* (the previous shape) redacted an already-truncated
1637 // string — a token straddling the cap survived as an unmatched prefix fragment, because both
1638 // truncations used MAX_ERROR_BODY_BYTES and left no protective gap. The token is now threaded
1639 // into the parse so redaction runs while the message is still unbounded.
1640 let token = "SECRETTOKEN1234567890"; // 21 bytes
1641 // Sweep the token across the cap rather than pinning one offset: the straddling position
1642 // depends on the byte length of the message's fixed prefix, which is not a contract and
1643 // shifts if the wording is ever edited. `straddled` guards against that drift silently
1644 // emptying this test — without it, a reworded message could move the boundary outside the
1645 // swept range and every assertion below would pass vacuously, losing the coverage that is
1646 // the entire reason this test exists.
1647 let mut straddled = false;
1648 for pad in (MAX_ERROR_BODY_BYTES - 80)..(MAX_ERROR_BODY_BYTES - 40) {
1649 let xml = format!("<{}{token}/>", "x".repeat(pad));
1650 // The unscrubbed message the same input would produce. A genuine straddle shows up as a
1651 // non-empty *proper prefix* of the token left at its end — distinct from the token
1652 // landing entirely past the cap (nothing of it remains) or entirely before it (all of it
1653 // remains), neither of which exercises the boundary.
1654 let Err(IbkrFlexError::Parse(plain)) =
1655 corporate_action::parse_corporate_actions_scrubbed(&xml, None)
1656 else {
1657 panic!("expected Parse from the token-less parse at pad {pad}");
1658 };
1659 straddled |= (1..token.len()).any(|cut| plain.ends_with(&token[..cut]));
1660
1661 match corporate_action::parse_corporate_actions_scrubbed(&xml, Some(token)) {
1662 Err(IbkrFlexError::Parse(message)) => {
1663 assert!(
1664 !message.contains(token),
1665 "full token must be redacted at pad {pad}"
1666 );
1667 // A straddling leak surfaces as a token *prefix* left at the truncation
1668 // boundary, so check every prefix length rather than one fixed substring — a
1669 // 5-byte fragment is as much a credential leak as a 6-byte one.
1670 for cut in 1..token.len() {
1671 assert!(
1672 !message.ends_with(&token[..cut]),
1673 "a {cut}-byte token fragment survived the boundary at pad {pad}, got: {message}"
1674 );
1675 }
1676 assert!(
1677 message.len() <= MAX_ERROR_BODY_BYTES,
1678 "redaction must not push the message over the cap at pad {pad}, len = {}",
1679 message.len()
1680 );
1681 }
1682 other => panic!("expected Parse, got {other:?}"),
1683 }
1684 }
1685 assert!(
1686 straddled,
1687 "the swept range no longer places the token across the {MAX_ERROR_BODY_BYTES}-byte cap \
1688 — widen it, or this test proves nothing"
1689 );
1690 }
1691
1692 #[test]
1693 fn corporate_action_parse_without_a_token_is_still_bounded() {
1694 // The public `parse_corporate_actions` has no credential in scope, so it only bounds. Pins
1695 // that routing both callers through one finaliser did not drop the bound on the `None` arm.
1696 let xml = format!("<{}/>", "€".repeat(1000)); // 3000 bytes of tag name, exceeds the cap
1697 match parse_corporate_actions(&xml) {
1698 Err(IbkrFlexError::Parse(message)) => assert!(
1699 message.len() <= MAX_ERROR_BODY_BYTES,
1700 "len = {}",
1701 message.len()
1702 ),
1703 other => panic!("expected Parse, got {other:?}"),
1704 }
1705 }
1706
1707 #[test]
1708 fn max_error_body_bytes_is_the_documented_cap() {
1709 // Pinned against a literal because the public rustdoc on `IbkrFlexError` states this bound as
1710 // "1 KiB" in prose (it cannot link a private constant under
1711 // `deny(rustdoc::private_intra_doc_links)`). Changing the constant without the docs would
1712 // otherwise drift silently.
1713 assert_eq!(MAX_ERROR_BODY_BYTES, 1024);
1714 }
1715
1716 #[test]
1717 fn flex_poll_policy_default_is_the_documented_budget() {
1718 // Asserted against literals, not against the constants the impl already reads: comparing
1719 // `Default::default()` to a struct built from those same constants is a tautology that
1720 // holds for any value, and — since `POLL_INITIAL_DELAY` and `POLL_INTERVAL` are both 5s —
1721 // would not even catch the two being transposed. These literals are the budget the public
1722 // rustdoc promises (~65s total), so a change here is a deliberate, visible contract change.
1723 assert_eq!(
1724 FlexPollPolicy::default(),
1725 FlexPollPolicy {
1726 initial_delay: Duration::from_secs(5),
1727 interval: Duration::from_secs(5),
1728 max_attempts: 12,
1729 }
1730 );
1731 }
1732
1733 #[test]
1734 fn sanitize_error_body_scrubs_reqwests_wire_encoded_token() {
1735 // A token with characters that encode differently on the wire (space -> `+`; `+ / ~` -> `%..`).
1736 // If a proxy reflects the *encoded* request line into its error page, the raw-token scrub
1737 // alone would miss it. This pins two properties: (1) `byte_serialize` produces exactly what
1738 // reqwest's `.query()` puts on the wire — so the scrub can't silently drift from the real
1739 // encoding if reqwest/url change — and (2) that encoded form is redacted from the body.
1740 let token = "abc def+g/h~i";
1741
1742 // What reqwest actually serialises onto the query string (`build()` only — nothing is sent).
1743 let request = reqwest::Client::new()
1744 .get("https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement")
1745 .query(&[("t", token), ("q", "1234567890"), ("v", FLEX_VERSION)])
1746 .build()
1747 .expect("request builds without sending");
1748 let wire_query = request.url().query().expect("query present");
1749
1750 let wire_form: String = url::form_urlencoded::byte_serialize(token.as_bytes()).collect();
1751 assert_ne!(
1752 wire_form, token,
1753 "test token must actually encode to something different, else it proves nothing"
1754 );
1755 assert!(
1756 wire_query.contains(&format!("t={wire_form}")),
1757 "byte_serialize must match reqwest's actual query encoding, else the wire-form scrub \
1758 silently stops matching; query = {wire_query}, wire_form = {wire_form}"
1759 );
1760
1761 // A proxy that echoes the received request line into its error page reflects the encoded form.
1762 let reflected = format!("502 Bad Gateway while proxying GET /GetStatement?{wire_query}");
1763 let scrubbed = sanitize_error_body(&reflected, token);
1764 assert!(
1765 !scrubbed.contains(&wire_form),
1766 "wire-encoded token must be redacted, got: {scrubbed}"
1767 );
1768 assert!(scrubbed.contains("[REDACTED]"));
1769 }
1770
1771 #[tokio::test]
1772 async fn poll_until_ready_zero_max_attempts_times_out_without_polling() {
1773 // `max_attempts == 0` is the documented "no polling requested" case: it must return
1774 // immediately with `PollTimeout { attempts: 0 }` and issue zero GetStatement calls (and skip
1775 // the initial delay). Zero-duration policy keeps the test instant.
1776 let policy = FlexPollPolicy {
1777 initial_delay: Duration::ZERO,
1778 interval: Duration::ZERO,
1779 max_attempts: 0,
1780 };
1781 let calls = std::cell::Cell::new(0u32);
1782 let result = poll_until_ready(&policy, "tok", async || {
1783 calls.set(calls.get() + 1);
1784 Ok((reqwest::StatusCode::OK, GET_READY.to_owned()))
1785 })
1786 .await;
1787 assert_eq!(calls.get(), 0, "max_attempts == 0 must not issue any poll");
1788 assert!(matches!(
1789 result,
1790 Err(IbkrFlexError::PollTimeout { attempts: 0 })
1791 ));
1792 }
1793
1794 #[tokio::test]
1795 async fn poll_until_ready_returns_as_soon_as_statement_is_ready() {
1796 // The loop must stop at the first Ready response, not run the full budget.
1797 let policy = FlexPollPolicy {
1798 initial_delay: Duration::ZERO,
1799 interval: Duration::ZERO,
1800 max_attempts: 5,
1801 };
1802 let calls = std::cell::Cell::new(0u32);
1803 let result = poll_until_ready(&policy, "tok", async || {
1804 calls.set(calls.get() + 1);
1805 // In progress on the first poll, ready on the second.
1806 let body = if calls.get() < 2 {
1807 GET_IN_PROGRESS
1808 } else {
1809 GET_READY
1810 };
1811 Ok((reqwest::StatusCode::OK, body.to_owned()))
1812 })
1813 .await;
1814 assert_eq!(calls.get(), 2, "must stop polling once Ready is seen");
1815 assert_eq!(result.expect("ready"), GET_READY);
1816 }
1817
1818 #[tokio::test]
1819 async fn poll_until_ready_exhausts_budget_and_reports_attempt_count() {
1820 // Every poll reports in-progress: the loop must run exactly `max_attempts` times — proving
1821 // the `1..=max_attempts` bound and the `attempt < max_attempts` sleep guard don't over- or
1822 // under-run — and surface that count in `PollTimeout`.
1823 let policy = FlexPollPolicy {
1824 initial_delay: Duration::ZERO,
1825 interval: Duration::ZERO,
1826 max_attempts: 3,
1827 };
1828 let calls = std::cell::Cell::new(0u32);
1829 let result = poll_until_ready(&policy, "tok", async || {
1830 calls.set(calls.get() + 1);
1831 Ok((reqwest::StatusCode::OK, GET_IN_PROGRESS.to_owned()))
1832 })
1833 .await;
1834 assert_eq!(calls.get(), 3, "must poll exactly max_attempts times");
1835 assert!(matches!(
1836 result,
1837 Err(IbkrFlexError::PollTimeout { attempts: 3 })
1838 ));
1839 }
1840
1841 #[tokio::test(start_paused = true)]
1842 async fn poll_until_ready_sleeps_initial_delay_first_then_interval_between_polls() {
1843 // The zero-duration tests above prove call counts and outcomes but not the *timing* wiring:
1844 // that `initial_delay` is awaited **before** the first poll and `interval` between subsequent
1845 // polls. Under `start_paused`, Tokio advances virtual time whenever the runtime is idle on a
1846 // timer, so each `sleep` completes deterministically and we can record the clock at every poll
1847 // without real sleeping.
1848 let policy = FlexPollPolicy {
1849 initial_delay: Duration::from_secs(5),
1850 interval: Duration::from_secs(2),
1851 max_attempts: 3,
1852 };
1853 let start = tokio::time::Instant::now();
1854 let elapsed_at_poll = std::cell::RefCell::new(Vec::<Duration>::new());
1855 let result = poll_until_ready(&policy, "tok", async || {
1856 elapsed_at_poll.borrow_mut().push(start.elapsed());
1857 // Always in-progress so the loop runs the full budget, exercising every inter-poll sleep.
1858 Ok((reqwest::StatusCode::OK, GET_IN_PROGRESS.to_owned()))
1859 })
1860 .await;
1861 assert!(matches!(
1862 result,
1863 Err(IbkrFlexError::PollTimeout { attempts: 3 })
1864 ));
1865
1866 // Poll 1 at t=5s proves `initial_delay` elapsed *before* the first poll (not t=0); polls 2 and
1867 // 3 one `interval` (2s) apart prove the between-poll sleep. A regression that polled before
1868 // sleeping, or that dropped a sleep, would shift these timestamps.
1869 assert_eq!(
1870 *elapsed_at_poll.borrow(),
1871 vec![
1872 Duration::from_secs(5),
1873 Duration::from_secs(7),
1874 Duration::from_secs(9),
1875 ],
1876 );
1877 }
1878}