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