Skip to main content

faucet_source_xml/
stream.rs

1//! XML stream executor.
2
3use crate::config::{XmlAuth, XmlPagination, XmlStreamConfig};
4use crate::convert;
5use async_trait::async_trait;
6use faucet_core::util::{self, DEFAULT_ERROR_BODY_MAX_LEN};
7use faucet_core::{AuthSpec, Credential, CredentialPlacement, FaucetError, SharedAuthProvider};
8use faucet_core::{Stream, StreamPage};
9use reqwest::Client;
10use serde_json::Value;
11use std::collections::{BTreeMap, HashMap};
12use std::pin::Pin;
13use std::time::Duration;
14
15/// Content fingerprint of a fetched page, used as a pagination loop guard: a
16/// server that ignores the page/offset parameter (or clamps to the last page)
17/// returns the same non-empty page on every request, which would otherwise loop
18/// forever. Stopping when two consecutive pages fingerprint identically mirrors
19/// the REST source's body-fingerprint guard (audit #146 H4/H5).
20fn page_fingerprint(records: &[Value]) -> u64 {
21    use std::hash::{Hash, Hasher};
22    // `serde_json::Value` is not `Hash`; hash its canonical string form.
23    let mut hasher = std::collections::hash_map::DefaultHasher::new();
24    records.len().hash(&mut hasher);
25    for r in records {
26        r.to_string().hash(&mut hasher);
27    }
28    hasher.finish()
29}
30
31/// Substitute `${name}` tokens with flow-captured login values (#567) — e.g. an
32/// Intacct `sessionid` captured from the login response and needed inside every
33/// data request's raw XML body. Only exact `${name}` occurrences for a captured
34/// `name` are replaced; any other `${...}` token is left untouched. Applied per
35/// request, after the parent-context substitution.
36fn substitute_captured(s: &str, captured: &BTreeMap<String, String>) -> String {
37    if captured.is_empty() || !s.contains("${") {
38        return s.to_string();
39    }
40    let mut out = s.to_string();
41    for (k, v) in captured {
42        out = out.replace(&format!("${{{k}}}"), v);
43    }
44    out
45}
46
47/// Retries on transient (5xx / connection) failures before giving up.
48const RETRY_MAX_ATTEMPTS: u32 = 3;
49/// Base exponential-backoff delay between retries.
50const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
51
52/// A configured XML API source that handles pagination and extraction.
53pub struct XmlStream {
54    config: XmlStreamConfig,
55    client: Client,
56    /// Optional shared auth provider. When present it takes precedence over
57    /// inline auth, so several sources can share one token with single-flight
58    /// refresh. Used by the CLI to resolve `auth: { ref }`, and by library
59    /// callers who construct one provider and inject it into many sources.
60    auth_provider: Option<SharedAuthProvider>,
61    /// Retry policy for transient request failures. Defaulted in `new()` to
62    /// reproduce the legacy `RETRY_MAX_ATTEMPTS` / `RETRY_BASE_BACKOFF`
63    /// constants; overridable via [`with_retry_policy`](Self::with_retry_policy).
64    retry_policy: faucet_core::RetryPolicy,
65}
66
67/// Attach a mutual-TLS client identity to the HTTP client builder (#495). Only
68/// compiled with the `mtls` feature; the stub errors so a `tls:` block on a
69/// build without the feature fails loudly instead of silently sending no cert.
70#[cfg(feature = "mtls")]
71fn apply_client_tls(
72    builder: reqwest::ClientBuilder,
73    tls: &faucet_core::TlsClientConfig,
74) -> Result<reqwest::ClientBuilder, FaucetError> {
75    let identity = build_identity(tls)?;
76    let mut builder = builder.identity(identity).use_native_tls();
77    if let Some(v) = &tls.min_version {
78        // `TlsClientConfig::validate` guarantees `v` is "1.2" or "1.3".
79        let version = if v == "1.3" {
80            reqwest::tls::Version::TLS_1_3
81        } else {
82            reqwest::tls::Version::TLS_1_2
83        };
84        builder = builder.min_tls_version(version);
85    }
86    Ok(builder)
87}
88
89#[cfg(not(feature = "mtls"))]
90fn apply_client_tls(
91    _builder: reqwest::ClientBuilder,
92    _tls: &faucet_core::TlsClientConfig,
93) -> Result<reqwest::ClientBuilder, FaucetError> {
94    Err(FaucetError::Config(
95        "a `tls:` (mutual-TLS) block is configured, but this build of \
96         faucet-source-xml lacks the `mtls` feature; rebuild with `--features mtls`"
97            .into(),
98    ))
99}
100
101/// Build a [`reqwest::Identity`] from the PEM pair or the PKCS#12 file. Errors
102/// never echo key material — only the backend's opaque parse message.
103#[cfg(feature = "mtls")]
104fn build_identity(tls: &faucet_core::TlsClientConfig) -> Result<reqwest::Identity, FaucetError> {
105    if let Some(p12_path) = &tls.client_identity_pkcs12 {
106        let der = std::fs::read(p12_path).map_err(|e| {
107            FaucetError::Config(format!(
108                "tls: could not read PKCS#12 file {p12_path:?}: {e}"
109            ))
110        })?;
111        let password = tls.pkcs12_password.as_deref().unwrap_or("");
112        reqwest::Identity::from_pkcs12_der(&der, password)
113            .map_err(|e| FaucetError::Config(format!("tls: invalid PKCS#12 identity: {e}")))
114    } else {
115        let cert = tls.client_cert.as_deref().unwrap_or_default();
116        let key = tls.client_key.as_deref().unwrap_or_default();
117        reqwest::Identity::from_pkcs8_pem(cert.as_bytes(), key.as_bytes())
118            .map_err(|e| FaucetError::Config(format!("tls: invalid PEM client identity: {e}")))
119    }
120}
121
122/// Map a [`Credential`] from a shared provider onto the XML [`XmlAuth`]
123/// representation so the existing header-application path can be reused.
124fn credential_to_auth(cred: Credential) -> XmlAuth {
125    match cred {
126        Credential::Bearer(token) => XmlAuth::Bearer { token },
127        Credential::Token(token) => XmlAuth::Custom {
128            headers: std::iter::once(("Authorization".to_string(), token)).collect(),
129        },
130        Credential::Basic { username, password } => XmlAuth::Basic { username, password },
131        Credential::Header { name, value } => XmlAuth::Custom {
132            headers: std::iter::once((name, value)).collect(),
133        },
134    }
135}
136
137impl XmlStream {
138    /// Create a new XML stream from the given configuration.
139    ///
140    /// Infallible for the common case. Prefer [`try_new`](Self::try_new) when the
141    /// config may carry a `tls:` (mutual-TLS) block: this panics if the client
142    /// (or the TLS identity) fails to build, matching the pre-existing
143    /// `Client::new()` behavior.
144    pub fn new(config: XmlStreamConfig) -> Self {
145        Self::try_new(config)
146            .expect("XmlStream::new: client build failed; use try_new() for fallible construction")
147    }
148
149    /// Fallible constructor — builds the HTTP client, including any mutual-TLS
150    /// client identity. The CLI registry uses this so a bad `tls:` block surfaces
151    /// as a typed error instead of a panic.
152    ///
153    /// Note: this does **not** run the full [`XmlStreamConfig::validate`] (SOAP
154    /// checks) — that stays at fetch time, unchanged — it only validates and
155    /// applies the `tls:` block, so `new()` remains infallible for non-TLS
156    /// configs exactly as before.
157    pub fn try_new(config: XmlStreamConfig) -> Result<Self, FaucetError> {
158        let mut builder = Client::builder();
159        if let Some(tls) = &config.tls {
160            tls.validate()?;
161            builder = apply_client_tls(builder, tls)?;
162        }
163        let client = builder
164            .build()
165            .map_err(|e| FaucetError::Config(format!("xml: failed to build HTTP client: {e}")))?;
166        Ok(Self {
167            config,
168            client,
169            auth_provider: None,
170            // Reproduce the legacy `execute_with_retry(RETRY_MAX_ATTEMPTS,
171            // RETRY_BASE_BACKOFF, …)` behavior exactly: `max_retries` is
172            // retries-after-first, so `max_attempts = RETRY_MAX_ATTEMPTS + 1`.
173            retry_policy: faucet_core::RetryPolicy {
174                max_attempts: RETRY_MAX_ATTEMPTS + 1,
175                backoff: faucet_core::BackoffKind::Exponential,
176                base: RETRY_BASE_BACKOFF,
177                max: Duration::from_secs(60),
178                jitter: true,
179                retry_on: faucet_core::RetryClassSet::default(),
180            },
181        })
182    }
183
184    /// Attach a custom [`RetryPolicy`](faucet_core::RetryPolicy) for transient
185    /// request failures, replacing the default derived from
186    /// `RETRY_MAX_ATTEMPTS` / `RETRY_BASE_BACKOFF`. Used by the CLI to inject a
187    /// pipeline-level `resilience:` policy into the source.
188    pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
189        self.retry_policy = policy;
190        self
191    }
192
193    /// Attach a shared [`AuthProvider`](faucet_core::AuthProvider). When set,
194    /// the provider supplies the credential for every request (taking precedence
195    /// over inline auth), so several sources can share one token with
196    /// single-flight refresh. Used by the CLI to resolve `auth: { ref }`, and by
197    /// library callers who construct one provider and inject it into many
198    /// sources.
199    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
200        self.auth_provider = Some(provider);
201        self
202    }
203
204    /// The effective record element path after applying SOAP ergonomics.
205    ///
206    /// When a `soap:` block is present with `path_relative_to_body` (the
207    /// default), the configured `records_element_path` is resolved relative to
208    /// the SOAP body — `Envelope.Body.` is prepended so the user writes
209    /// `GetUsersResponse.Users.User`. Otherwise the configured path is used
210    /// verbatim (the non-SOAP behavior).
211    fn effective_records_path(&self) -> Option<String> {
212        match (&self.config.soap, &self.config.records_element_path) {
213            (Some(soap), Some(path)) if soap.path_relative_to_body => {
214                Some(format!("Envelope.Body.{path}"))
215            }
216            (_, path) => path.clone(),
217        }
218    }
219
220    /// Eagerly convert one HTTP page of XML to JSON and extract its records,
221    /// applying SOAP fault handling when a `soap:` block is present.
222    ///
223    /// When `soap` is absent this reproduces the legacy eager path exactly
224    /// (`xml_to_json` + `extract_at_path`), so non-SOAP behavior is unchanged.
225    /// When `soap` is present it additionally detects a SOAP `<Fault>` under
226    /// `Envelope.Body`: with `fault_as_error` it raises
227    /// [`FaucetError::Source`]; otherwise it emits zero records and logs the
228    /// fault once (tracked via `fault_logged`).
229    fn extract_records_eager(
230        &self,
231        xml_text: &str,
232        fault_logged: &mut bool,
233    ) -> Result<Vec<Value>, FaucetError> {
234        let doc = convert::xml_to_json(xml_text)?;
235
236        if let Some(soap) = &self.config.soap
237            && let Some(message) = convert::detect_soap_fault(&doc)
238        {
239            if soap.fault_as_error {
240                return Err(FaucetError::Source(format!("SOAP fault: {message}")));
241            }
242            if !*fault_logged {
243                tracing::warn!(
244                    fault = %message,
245                    "SOAP fault in response; emitting zero records (fault_as_error=false)"
246                );
247                *fault_logged = true;
248            }
249            return Ok(Vec::new());
250        }
251
252        let records = match self.effective_records_path() {
253            Some(path) => convert::extract_at_path(&doc, &path),
254            None => vec![doc],
255        };
256        Ok(records)
257    }
258
259    /// Fetch all records across all pages.
260    pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
261        self.fetch_all_with_context(&HashMap::new()).await
262    }
263
264    /// Fetch all records, substituting parent context into path, query_params, and body.
265    async fn fetch_all_with_context(
266        &self,
267        context: &HashMap<String, serde_json::Value>,
268    ) -> Result<Vec<Value>, FaucetError> {
269        self.config.validate()?;
270
271        let mut all_records = Vec::new();
272        let mut pages_fetched = 0usize;
273        let mut offset = 0usize;
274        let mut page_number = None;
275        let mut prev_fingerprint: Option<u64> = None;
276        let mut fault_logged = false;
277        // Body-cursor pagination (#544): the request body for pages after the
278        // first (the rendered `next_body`), and the last-seen token for the
279        // loop guard. `None` on the first page → use the configured body/soap.
280        let mut body_override: Option<String> = None;
281        let mut prev_token: Option<String> = None;
282
283        // Initialize pagination state.
284        if let Some(XmlPagination::PageNumber { start_page, .. }) = &self.config.pagination {
285            page_number = Some(*start_page);
286        }
287
288        loop {
289            if let Some(max) = self.config.max_pages
290                && pages_fetched >= max
291            {
292                tracing::warn!("max pages ({max}) reached");
293                break;
294            }
295
296            let mut params = self.config.query_params.clone();
297            self.apply_pagination_params(&mut params, page_number, offset);
298
299            let xml_text = self
300                .execute_request(&params, context, body_override.as_deref())
301                .await?;
302            // #540: when a decode pipeline is configured, records come from the
303            // decoded output (extract → base64/gunzip/unzip → parse) rather than
304            // navigating `records_element_path` over the raw XML.
305            let records = if self.config.decode.is_empty() {
306                self.extract_records_eager(&xml_text, &mut fault_logged)?
307            } else {
308                crate::decode::run_decode(xml_text.as_bytes(), &self.config.decode).await?
309            };
310
311            let record_count = records.len();
312            let fingerprint = page_fingerprint(&records);
313            pages_fetched += 1;
314
315            // Loop guard: a server that ignores the page/offset parameter (or
316            // clamps to the last page) returns the same non-empty page forever.
317            // Stop when two consecutive pages are identical (audit #146 H4/H5) —
318            // and do it BEFORE appending, so the duplicate page's records are
319            // never emitted to the sink a second time (audit #321 M4).
320            if record_count > 0 && prev_fingerprint == Some(fingerprint) {
321                tracing::warn!(
322                    "XML pagination returned an identical page; stopping to avoid an infinite loop"
323                );
324                break;
325            }
326            prev_fingerprint = Some(fingerprint);
327            all_records.extend(records);
328
329            // Advance pagination or stop.
330            match &self.config.pagination {
331                Some(XmlPagination::PageNumber { page_size, .. }) => {
332                    if record_count == 0 {
333                        break;
334                    }
335                    // Stop if page_size is set and we got fewer records than the page size.
336                    if let Some(size) = page_size
337                        && record_count < *size
338                    {
339                        break;
340                    }
341                    page_number = page_number.map(|p| p + 1);
342                }
343                Some(XmlPagination::Offset { limit, .. }) => {
344                    if record_count < *limit {
345                        break;
346                    }
347                    offset += record_count;
348                }
349                Some(XmlPagination::BodyCursor {
350                    next_token_path,
351                    next_body,
352                }) => {
353                    // Read the continuation token from THIS page's response.
354                    match crate::decode::xml_extract_text(xml_text.as_bytes(), next_token_path) {
355                        // Absent/empty token → done. Repeated token → loop guard.
356                        Some(t)
357                            if !t.trim().is_empty()
358                                && prev_token.as_deref() != Some(t.as_str()) =>
359                        {
360                            body_override = Some(next_body.replace("${next_token}", &t));
361                            prev_token = Some(t);
362                        }
363                        _ => break,
364                    }
365                }
366                None => break,
367            }
368        }
369
370        tracing::info!(
371            records = all_records.len(),
372            pages = pages_fetched,
373            "XML fetch complete"
374        );
375        Ok(all_records)
376    }
377
378    fn apply_pagination_params(
379        &self,
380        params: &mut HashMap<String, String>,
381        page_number: Option<usize>,
382        offset: usize,
383    ) {
384        match &self.config.pagination {
385            Some(XmlPagination::PageNumber {
386                param_name,
387                page_size,
388                page_size_param,
389                ..
390            }) => {
391                if let Some(page) = page_number {
392                    params.insert(param_name.clone(), page.to_string());
393                }
394                if let (Some(size), Some(param)) = (page_size, page_size_param) {
395                    params.insert(param.clone(), size.to_string());
396                }
397            }
398            Some(XmlPagination::Offset {
399                offset_param,
400                limit_param,
401                limit,
402            }) => {
403                params.insert(offset_param.clone(), offset.to_string());
404                params.insert(limit_param.clone(), limit.to_string());
405            }
406            // Body-cursor paging carries its token in the request body, not the
407            // query string — nothing to add here.
408            Some(XmlPagination::BodyCursor { .. }) => {}
409            None => {}
410        }
411    }
412
413    async fn execute_request(
414        &self,
415        params: &HashMap<String, String>,
416        context: &HashMap<String, serde_json::Value>,
417        body_override: Option<&str>,
418    ) -> Result<String, FaucetError> {
419        let path = if context.is_empty() {
420            self.config.path.clone()
421        } else {
422            faucet_core::util::substitute_context(&self.config.path, context)
423        };
424
425        // #567 rich per-request auth: a flow provider may override the base-URL,
426        // place credentials across header/query/cookie, and expose captured
427        // login values for `${name}` substitution into the raw body/headers
428        // (an Intacct `sessionid`). When it contributes anything it supersedes
429        // the plain credential() path below.
430        let mut base_url = self.config.base_url.clone();
431        let mut ra_headers: Vec<(String, String)> = Vec::new();
432        let mut ra_query: Vec<(String, String)> = Vec::new();
433        let mut ra_cookies: Vec<(String, String)> = Vec::new();
434        let mut captured: BTreeMap<String, String> = BTreeMap::new();
435        let mut used_request_auth = false;
436        if let Some(provider) = &self.auth_provider {
437            let q: BTreeMap<String, String> =
438                params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
439            let ra = provider
440                .request_auth(self.config.method.as_str(), &base_url, &q)
441                .await?;
442            if !ra.is_empty() {
443                used_request_auth = true;
444                if let Some(b) = ra.base_url {
445                    base_url = b;
446                }
447                for p in ra.placements {
448                    match p {
449                        CredentialPlacement::Header { name, value } => {
450                            ra_headers.push((name, value))
451                        }
452                        CredentialPlacement::Query { name, value } => ra_query.push((name, value)),
453                        CredentialPlacement::Cookie { name, value } => {
454                            ra_cookies.push((name, value))
455                        }
456                        // BodyField is JSON-body-specific; an XML body carries a
457                        // captured value via `${name}` substitution instead.
458                        _ => {}
459                    }
460                }
461                captured = ra.captured;
462            }
463        }
464
465        let url = format!("{}/{}", base_url, path.trim_start_matches('/'));
466
467        // Query values: parent context + captured substitution, then the flow
468        // provider's query placements.
469        let mut resolved_params: HashMap<String, String> = params
470            .iter()
471            .map(|(k, v)| {
472                let v = if context.is_empty() {
473                    v.clone()
474                } else {
475                    faucet_core::util::substitute_context(v, context)
476                };
477                (k.clone(), substitute_captured(&v, &captured))
478            })
479            .collect();
480        for (k, v) in ra_query {
481            resolved_params.insert(k, v);
482        }
483
484        // Config headers with captured substitution, plus the flow provider's
485        // header + cookie placements.
486        let mut header_map = reqwest::header::HeaderMap::new();
487        for (name, value) in self.config.headers.iter() {
488            let sv = substitute_captured(value.to_str().unwrap_or_default(), &captured);
489            match reqwest::header::HeaderValue::from_str(&sv) {
490                Ok(hv) => header_map.insert(name.clone(), hv),
491                Err(_) => header_map.insert(name.clone(), value.clone()),
492            };
493        }
494        for (name, value) in &ra_headers {
495            if let (Ok(n), Ok(v)) = (
496                reqwest::header::HeaderName::from_bytes(name.as_bytes()),
497                reqwest::header::HeaderValue::from_str(value),
498            ) {
499                header_map.insert(n, v);
500            }
501        }
502        if !ra_cookies.is_empty() {
503            let cookie = ra_cookies
504                .iter()
505                .map(|(n, v)| format!("{n}={v}"))
506                .collect::<Vec<_>>()
507                .join("; ");
508            if let Ok(v) = reqwest::header::HeaderValue::from_str(&cookie) {
509                header_map.insert(reqwest::header::COOKIE, v);
510            }
511        }
512
513        let mut req = self
514            .client
515            .request(self.config.method.clone(), &url)
516            .headers(header_map)
517            .query(&resolved_params);
518
519        // Resolve inline / single credentials — unless the flow provider already
520        // supplied the request auth above.
521        if !used_request_auth {
522            let effective_auth: XmlAuth = if let Some(provider) = &self.auth_provider {
523                credential_to_auth(provider.credential().await?)
524            } else {
525                match &self.config.auth {
526                    AuthSpec::Inline(a) => a.clone(),
527                    AuthSpec::Reference(r) => {
528                        return Err(FaucetError::Auth(format!(
529                            "auth references provider '{}' but no provider was supplied; \
530                             set one via the CLI `auth:` catalog or `with_auth_provider`",
531                            r.name
532                        )));
533                    }
534                }
535            };
536
537            match &effective_auth {
538                XmlAuth::None => {}
539                XmlAuth::Bearer { token } => {
540                    req = req.bearer_auth(token);
541                }
542                XmlAuth::Basic { username, password } => {
543                    req = req.basic_auth(username, Some(password));
544                }
545                XmlAuth::Custom { headers } => {
546                    let mut hm = reqwest::header::HeaderMap::new();
547                    for (name, value) in headers {
548                        let n = reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(
549                            |e| {
550                                FaucetError::Auth(format!(
551                                    "invalid custom header name {name:?}: {e}"
552                                ))
553                            },
554                        )?;
555                        let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
556                            FaucetError::Auth(format!(
557                                "invalid custom header value for {name:?}: {e}"
558                            ))
559                        })?;
560                        hm.insert(n, v);
561                    }
562                    req = req.headers(hm);
563                }
564            }
565        }
566
567        // Set the request body for POST (SOAP), with context substitution.
568        //
569        // A `soap:` block takes precedence: it assembles the envelope and
570        // injects the version-appropriate headers (Content-Type + SOAPAction).
571        // These headers are set here regardless of the `auth` variant, so real
572        // bearer / basic auth (applied above) is left untouched. Otherwise the
573        // legacy raw-`body` path is used verbatim (byte-for-byte unchanged).
574        if let Some(ob) = body_override {
575            // #544 body-cursor: a rendered `next_body` replaces the request body
576            // for pages after the first (e.g. Intacct `readMore`). Takes
577            // precedence over the configured soap/raw body.
578            let resolved = if context.is_empty() {
579                ob.to_string()
580            } else {
581                faucet_core::util::substitute_context(ob, context)
582            };
583            let resolved = substitute_captured(&resolved, &captured);
584            req = req
585                .header("Content-Type", "text/xml; charset=utf-8")
586                .body(resolved);
587        } else if let Some(soap) = &self.config.soap {
588            let inner = soap.body_inner.as_deref().unwrap_or("");
589            let resolved_inner = if context.is_empty() {
590                inner.to_string()
591            } else {
592                faucet_core::util::substitute_context(inner, context)
593            };
594            let resolved_inner = substitute_captured(&resolved_inner, &captured);
595            let envelope = soap.build_envelope(&resolved_inner);
596            req = req
597                .header("Content-Type", soap.content_type())
598                .body(envelope);
599            if let Some(action) = soap.soap_action_header() {
600                req = req.header("SOAPAction", action);
601            }
602        } else if let Some(body) = &self.config.body {
603            let resolved_body = if context.is_empty() {
604                body.clone()
605            } else {
606                faucet_core::util::substitute_context(body, context)
607            };
608            let resolved_body = substitute_captured(&resolved_body, &captured);
609            req = req
610                .header("Content-Type", "text/xml; charset=utf-8")
611                .body(resolved_body);
612        }
613
614        // Retry transient failures (5xx / connection resets) with jittered
615        // backoff, matching the REST source's reliability layer (#78/#16).
616        // The request body is a String, so `try_clone` always succeeds.
617        faucet_core::execute_with_policy(&self.retry_policy, None, || {
618            let attempt = req.try_clone();
619            async move {
620                let req = attempt.ok_or_else(|| {
621                    FaucetError::Source("xml: request is not cloneable for retry".into())
622                })?;
623                let resp = req.send().await.map_err(FaucetError::Http)?;
624                let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
625                resp.text().await.map_err(FaucetError::Http)
626            }
627        })
628        .await
629    }
630}
631
632#[async_trait]
633impl faucet_core::Source for XmlStream {
634    async fn fetch_with_context(
635        &self,
636        context: &std::collections::HashMap<String, serde_json::Value>,
637    ) -> Result<Vec<Value>, FaucetError> {
638        self.fetch_all_with_context(context).await
639    }
640
641    /// Stream records from the XML response without materialising the whole
642    /// document tree. The event-driven parser only builds JSON values for
643    /// elements matching [`XmlStreamConfig::records_element_path`]; other
644    /// elements are observed and discarded, so client-side memory is bounded
645    /// at `O(batch_size * record_size)` regardless of how large the document
646    /// is.
647    ///
648    /// Records are accumulated into a buffer of
649    /// [`XmlStreamConfig::batch_size`] entries and yielded as a
650    /// [`StreamPage`] once the buffer is full. The trailing partial buffer
651    /// (if any) is emitted after the parser hits EOF and all pagination
652    /// rounds drain.
653    ///
654    /// The trait-level `batch_size` argument is intentionally ignored in
655    /// favour of the config field — the config is the user-facing knob the
656    /// README documents, and routing the pipeline-supplied hint through it
657    /// would silently override an explicit config value. `batch_size = 0`
658    /// drains every page into a single emitted page.
659    ///
660    /// Bookmarks are always `None` — the XML source has no
661    /// incremental-replication mode today; pagination only walks the
662    /// API's own page-number / offset cursor.
663    fn stream_pages<'a>(
664        &'a self,
665        context: &'a HashMap<String, Value>,
666        _batch_size: usize,
667    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
668        let batch_size = self.config.batch_size;
669        let owned_context = context.clone();
670
671        Box::pin(async_stream::try_stream! {
672            self.config.validate()?;
673
674            // A decode pipeline (#540) and body-cursor paging (#544) both buffer
675            // the whole payload, so they can't use the event-driven streaming
676            // parser — fall back to the eager fetch and emit it in chunks.
677            if !self.config.decode.is_empty()
678                || matches!(self.config.pagination, Some(XmlPagination::BodyCursor { .. }))
679            {
680                let records = self.fetch_all_with_context(&owned_context).await?;
681                let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
682                for c in records.chunks(chunk) {
683                    yield StreamPage { records: c.to_vec(), bookmark: None };
684                }
685                return;
686            }
687
688            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
689            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
690            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
691            let mut total = 0usize;
692            let mut pages_fetched = 0usize;
693            let mut offset = 0usize;
694            let mut page_number = None;
695            let mut prev_fingerprint: Option<u64> = None;
696            let mut fault_logged = false;
697
698            if let Some(XmlPagination::PageNumber { start_page, .. }) =
699                &self.config.pagination
700            {
701                page_number = Some(*start_page);
702            }
703
704            loop {
705                if let Some(max) = self.config.max_pages
706                    && pages_fetched >= max
707                {
708                    tracing::warn!("max pages ({max}) reached");
709                    break;
710                }
711
712                let mut params = self.config.query_params.clone();
713                self.apply_pagination_params(&mut params, page_number, offset);
714
715                let xml_text = self.execute_request(&params, &owned_context, None).await?;
716
717                // Event-driven extraction: only the matched subtree is
718                // ever materialised. The closure pushes into the local
719                // buffer; once it crosses `chunk`, the surrounding loop
720                // can flush, but we can't `yield` from inside the closure,
721                // so we collect this HTTP page's records into a scratch
722                // Vec and then iterate them after.
723                //
724                // A `soap:` block routes through the eager converter so the
725                // SOAP `<Fault>` check and `Envelope.Body.`-relative path
726                // resolution apply (SOAP responses are small — the bounded-
727                // memory streaming path is reserved for the non-SOAP case,
728                // which stays byte-for-byte unchanged).
729                let mut page_records: Vec<Value> = Vec::new();
730                if self.config.soap.is_some() {
731                    page_records = self.extract_records_eager(&xml_text, &mut fault_logged)?;
732                } else {
733                    convert::stream_extract(
734                        &xml_text,
735                        self.config.records_element_path.as_deref(),
736                        |rec| page_records.push(rec),
737                    )?;
738                }
739
740                let record_count = page_records.len();
741                let fingerprint = page_fingerprint(&page_records);
742                pages_fetched += 1;
743
744                // Loop guard: stop when two consecutive pages are identical — a
745                // server ignoring the page/offset parameter (or clamping to the
746                // last page) returns the same non-empty page forever (#146 H4/H5).
747                // Check BEFORE buffering/yielding so the duplicate page's records
748                // are not emitted to the sink a second time (audit #321 M4).
749                if record_count > 0 && prev_fingerprint == Some(fingerprint) {
750                    tracing::warn!(
751                        "XML pagination returned an identical page; stopping to avoid an infinite loop"
752                    );
753                    break;
754                }
755                prev_fingerprint = Some(fingerprint);
756
757                for rec in page_records.drain(..) {
758                    buffer.push(rec);
759                    if buffer.len() >= chunk {
760                        let flush = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
761                        total += flush.len();
762                        yield StreamPage { records: flush, bookmark: None };
763                    }
764                }
765
766                // Advance pagination using the same rules as
767                // `fetch_all_with_context`.
768                match &self.config.pagination {
769                    Some(XmlPagination::PageNumber { page_size, .. }) => {
770                        if record_count == 0 {
771                            break;
772                        }
773                        if let Some(size) = page_size
774                            && record_count < *size
775                        {
776                            break;
777                        }
778                        page_number = page_number.map(|p| p + 1);
779                    }
780                    Some(XmlPagination::Offset { limit, .. }) => {
781                        if record_count < *limit {
782                            break;
783                        }
784                        offset += record_count;
785                    }
786                    // Handled by the buffered fallback above (this streaming
787                    // path is never entered for body-cursor paging).
788                    Some(XmlPagination::BodyCursor { .. }) => break,
789                    None => break,
790                }
791            }
792
793            if !buffer.is_empty() {
794                total += buffer.len();
795                yield StreamPage { records: buffer, bookmark: None };
796            }
797
798            tracing::info!(
799                records = total,
800                pages = pages_fetched,
801                batch_size,
802                "XML source stream complete",
803            );
804        })
805    }
806
807    fn connector_name(&self) -> &'static str {
808        "xml"
809    }
810
811    fn config_schema(&self) -> serde_json::Value {
812        serde_json::to_value(faucet_core::schema_for!(XmlStreamConfig))
813            .expect("schema serialization")
814    }
815
816    fn dataset_uri(&self) -> String {
817        format!(
818            "{}{}",
819            faucet_core::redact_uri_credentials(&self.config.base_url),
820            self.config.path
821        )
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use crate::config::{SoapConfig, SoapVersion};
829    use faucet_core::Source;
830
831    fn soap_response(records: &str) -> String {
832        format!(
833            "<Envelope xmlns=\"http://schemas.xmlsoap.org/soap/envelope/\"><Body>\
834             <GetUsersResponse><Users>{records}</Users></GetUsersResponse></Body></Envelope>"
835        )
836    }
837
838    #[test]
839    fn effective_path_prepends_envelope_body_by_default() {
840        let source = XmlStream::new(
841            XmlStreamConfig::new("https://s", "/svc")
842                .method(reqwest::Method::POST)
843                .records_element_path("GetUsersResponse.Users.User")
844                .with_soap(SoapConfig {
845                    body_inner: Some("<Op/>".into()),
846                    ..Default::default()
847                }),
848        );
849        assert_eq!(
850            source.effective_records_path().as_deref(),
851            Some("Envelope.Body.GetUsersResponse.Users.User")
852        );
853    }
854
855    #[test]
856    fn effective_path_absolute_override_when_not_relative() {
857        let source = XmlStream::new(
858            XmlStreamConfig::new("https://s", "/svc")
859                .method(reqwest::Method::POST)
860                .records_element_path("Envelope.Body.GetUsersResponse.Users.User")
861                .with_soap(SoapConfig {
862                    body_inner: Some("<Op/>".into()),
863                    path_relative_to_body: false,
864                    ..Default::default()
865                }),
866        );
867        assert_eq!(
868            source.effective_records_path().as_deref(),
869            Some("Envelope.Body.GetUsersResponse.Users.User")
870        );
871    }
872
873    #[test]
874    fn effective_path_unchanged_without_soap() {
875        let source = XmlStream::new(
876            XmlStreamConfig::new("https://s", "/svc").records_element_path("root.item"),
877        );
878        assert_eq!(
879            source.effective_records_path().as_deref(),
880            Some("root.item")
881        );
882    }
883
884    #[test]
885    fn extract_records_eager_resolves_relative_soap_path() {
886        let source = XmlStream::new(
887            XmlStreamConfig::new("https://s", "/svc")
888                .method(reqwest::Method::POST)
889                .records_element_path("GetUsersResponse.Users.User")
890                .with_soap(SoapConfig {
891                    body_inner: Some("<Op/>".into()),
892                    ..Default::default()
893                }),
894        );
895        let xml = soap_response("<User><Name>Alice</Name></User><User><Name>Bob</Name></User>");
896        let mut logged = false;
897        let records = source.extract_records_eager(&xml, &mut logged).unwrap();
898        assert_eq!(records.len(), 2);
899        assert_eq!(records[0]["Name"], "Alice");
900        assert_eq!(records[1]["Name"], "Bob");
901    }
902
903    #[test]
904    fn extract_records_eager_fault_as_error_raises_source_error() {
905        let source = XmlStream::new(
906            XmlStreamConfig::new("https://s", "/svc")
907                .method(reqwest::Method::POST)
908                .records_element_path("GetUsersResponse.Users.User")
909                .with_soap(SoapConfig {
910                    body_inner: Some("<Op/>".into()),
911                    ..Default::default()
912                }),
913        );
914        let xml = r#"<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/"><Body>
915            <Fault><faultcode>Server</faultcode><faultstring>kaboom</faultstring></Fault>
916        </Body></Envelope>"#;
917        let mut logged = false;
918        let err = source.extract_records_eager(xml, &mut logged).unwrap_err();
919        assert!(
920            matches!(&err, FaucetError::Source(m) if m.contains("SOAP fault") && m.contains("kaboom")),
921            "got {err:?}"
922        );
923    }
924
925    #[test]
926    fn extract_records_eager_fault_not_error_yields_zero_records() {
927        let source = XmlStream::new(
928            XmlStreamConfig::new("https://s", "/svc")
929                .method(reqwest::Method::POST)
930                .records_element_path("GetUsersResponse.Users.User")
931                .with_soap(SoapConfig {
932                    body_inner: Some("<Op/>".into()),
933                    fault_as_error: false,
934                    ..Default::default()
935                }),
936        );
937        let xml = r#"<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/"><Body>
938            <Fault><faultstring>ignored</faultstring></Fault>
939        </Body></Envelope>"#;
940        let mut logged = false;
941        let records = source.extract_records_eager(xml, &mut logged).unwrap();
942        assert!(records.is_empty());
943        assert!(logged, "fault should be recorded as logged");
944    }
945
946    #[test]
947    fn extract_records_eager_non_soap_matches_legacy_eager_path() {
948        // Regression: with no soap block, extraction is byte-for-byte the
949        // legacy xml_to_json + extract_at_path behavior.
950        let source = XmlStream::new(
951            XmlStreamConfig::new("https://s", "/svc").records_element_path("root.item"),
952        );
953        let xml = "<root><item><id>1</id></item><item><id>2</id></item></root>";
954        let mut logged = false;
955        let records = source.extract_records_eager(xml, &mut logged).unwrap();
956        let legacy = convert::extract_at_path(&convert::xml_to_json(xml).unwrap(), "root.item");
957        assert_eq!(records, legacy);
958        assert_eq!(records.len(), 2);
959    }
960
961    #[tokio::test]
962    async fn fetch_all_rejects_invalid_soap_config() {
963        // A soap block with the default GET method fails validation before any
964        // request is attempted.
965        let source = XmlStream::new(
966            XmlStreamConfig::new("https://s", "/svc").with_soap(SoapConfig::default()),
967        );
968        let err = source.fetch_all().await.unwrap_err();
969        assert!(matches!(&err, FaucetError::Config(_)), "got {err:?}");
970    }
971
972    #[test]
973    fn soap12_content_type_used_for_envelope() {
974        // Sanity: a 1.2 soap block produces the 1.2 content type.
975        let soap = SoapConfig {
976            version: SoapVersion::Soap12,
977            action: Some("urn:Op".into()),
978            ..Default::default()
979        };
980        assert!(soap.content_type().starts_with("application/soap+xml"));
981    }
982
983    #[test]
984    fn dataset_uri_combines_base_and_path() {
985        let source = XmlStream::new(XmlStreamConfig::new(
986            "https://soap.example.com",
987            "/api/v1/service",
988        ));
989        assert_eq!(
990            source.dataset_uri(),
991            "https://soap.example.com/api/v1/service"
992        );
993    }
994
995    #[test]
996    fn dataset_uri_redacts_credentials() {
997        let source = XmlStream::new(XmlStreamConfig::new(
998            "https://user:pass@soap.example.com",
999            "/svc",
1000        ));
1001        assert_eq!(source.dataset_uri(), "https://soap.example.com/svc");
1002    }
1003
1004    #[test]
1005    fn default_retry_policy_reproduces_legacy_constants() {
1006        let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"));
1007        assert_eq!(source.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
1008        assert_eq!(source.retry_policy.base, RETRY_BASE_BACKOFF);
1009    }
1010
1011    #[test]
1012    fn with_retry_policy_overrides_the_default() {
1013        let policy = faucet_core::RetryPolicy {
1014            max_attempts: 9,
1015            base: Duration::from_secs(7),
1016            ..faucet_core::RetryPolicy::default()
1017        };
1018        let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"))
1019            .with_retry_policy(policy);
1020        assert_eq!(source.retry_policy.max_attempts, 9);
1021        assert_eq!(source.retry_policy.base, Duration::from_secs(7));
1022    }
1023}
1024
1025/// Mutual-TLS unit tests (#495) — lib-level for reliable llvm-cov attribution.
1026#[cfg(all(test, feature = "mtls"))]
1027mod mtls_tests {
1028    use super::*;
1029    use faucet_core::TlsClientConfig;
1030
1031    const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
1032    const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
1033
1034    fn pem() -> TlsClientConfig {
1035        TlsClientConfig {
1036            client_cert: Some(CERT.to_string()),
1037            client_key: Some(KEY.to_string()),
1038            ..Default::default()
1039        }
1040    }
1041
1042    #[test]
1043    fn pem_identity_builds() {
1044        let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(pem());
1045        assert!(XmlStream::try_new(cfg).is_ok());
1046    }
1047
1048    #[test]
1049    fn min_version_branches_are_exercised() {
1050        let mut tls = pem();
1051        tls.min_version = Some("1.2".into());
1052        assert!(XmlStream::try_new(XmlStreamConfig::new("https://x.test", "/y").tls(tls)).is_ok());
1053        // 1.3 exercises the other branch; some native-tls backends reject a 1.3
1054        // floor at build time, so only require it not to panic.
1055        let mut tls = pem();
1056        tls.min_version = Some("1.3".into());
1057        let _ = XmlStream::try_new(XmlStreamConfig::new("https://x.test", "/y").tls(tls));
1058    }
1059
1060    #[test]
1061    fn pkcs12_identity_builds() {
1062        let p12 = concat!(
1063            env!("CARGO_MANIFEST_DIR"),
1064            "/tests/fixtures/mtls/identity.p12"
1065        );
1066        let tls = TlsClientConfig {
1067            client_identity_pkcs12: Some(p12.to_string()),
1068            pkcs12_password: Some("changeit".into()),
1069            ..Default::default()
1070        };
1071        let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
1072        assert!(XmlStream::try_new(cfg).is_ok());
1073    }
1074
1075    #[test]
1076    fn invalid_pem_errors_without_leaking_key() {
1077        let tls = TlsClientConfig {
1078            client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
1079            client_key: Some("SUPERSECRETKEY".into()),
1080            ..Default::default()
1081        };
1082        let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
1083        let err = XmlStream::try_new(cfg)
1084            .map(|_| ())
1085            .expect_err("bad PEM must error");
1086        assert!(!err.to_string().contains("SUPERSECRETKEY"));
1087    }
1088
1089    #[test]
1090    fn invalid_tls_shape_errors() {
1091        // Both PEM and PKCS#12 set → validation error.
1092        let mut tls = pem();
1093        tls.client_identity_pkcs12 = Some("/x.p12".into());
1094        let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
1095        assert!(XmlStream::try_new(cfg).is_err());
1096    }
1097
1098    #[test]
1099    fn missing_pkcs12_file_errors() {
1100        let tls = TlsClientConfig {
1101            client_identity_pkcs12: Some("/no/such.p12".into()),
1102            pkcs12_password: Some("x".into()),
1103            ..Default::default()
1104        };
1105        let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
1106        assert!(XmlStream::try_new(cfg).is_err());
1107    }
1108
1109    #[test]
1110    fn config_validate_checks_tls() {
1111        assert!(
1112            XmlStreamConfig::new("https://x.test", "/y")
1113                .tls(pem())
1114                .validate()
1115                .is_ok()
1116        );
1117        let mut bad = pem();
1118        bad.client_identity_pkcs12 = Some("/x.p12".into());
1119        assert!(
1120            XmlStreamConfig::new("https://x.test", "/y")
1121                .tls(bad)
1122                .validate()
1123                .is_err()
1124        );
1125    }
1126}