Skip to main content

questdb/egress/
config.rs

1/*******************************************************************************
2 *     ___                  _   ____  ____
3 *    / _ \ _   _  ___  ___| |_|  _ \| __ )
4 *   | | | | | | |/ _ \/ __| __| | | |  _ \
5 *   | |_| | |_| |  __/\__ \ |_| |_| | |_) |
6 *    \__\_\\__,_|\___||___/\__|____/|____/
7 *
8 *  Copyright (c) 2014-2019 Appsicle
9 *  Copyright (c) 2019-2025 QuestDB
10 *
11 *  Licensed under the Apache License, Version 2.0 (the "License");
12 *  you may not use this file except in compliance with the License.
13 *  You may obtain a copy of the License at
14 *
15 *  http://www.apache.org/licenses/LICENSE-2.0
16 *
17 *  Unless required by applicable law or agreed to in writing, software
18 *  distributed under the License is distributed on an "AS IS" BASIS,
19 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 *  See the License for the specific language governing permissions and
21 *  limitations under the License.
22 *
23 ******************************************************************************/
24
25//! Reader configuration.
26//!
27//! Connect-string format mirrors the ingress sender's:
28//!
29//! ```text
30//! ws::addr=host:port;key=value;key=value;...
31//! wss::addr=host:port;...    # TLS
32//! ```
33//!
34//! Recognised keys (defaults shown in parentheses):
35//!
36//! | Key                | Notes                                                    |
37//! |--------------------|----------------------------------------------------------|
38//! | `addr`             | required; `host:port` or `host`                          |
39//! | `path`             | endpoint path (`/read/v1`)                               |
40//! | `max_version`      | QWP version to advertise (`1`)                           |
41//! | `compression`      | `raw` / `zstd` / `auto` — `zstd`/`auto` require the `sync-reader-zstd` feature (`raw`) |
42//! | `compression_level`| `zstd` level advertised in `X-QWP-Accept-Encoding` as `zstd;level=N`; `[1,22]`, default `1` (server clamps to `[1,9]`); ignored when `compression=raw` |
43//! | `max_batch_rows`   | sent only when non-zero (`0` = server default)           |
44//! | `client_id`        | optional; sent only when set                             |
45//! | `target`           | `any`/`primary`/`replica` (default `any`)                |
46//! | `failover`         | `true`/`false` — mid-query reconnect on transport failure (`true`) |
47//! | `failover_max_attempts`        | total Execute attempts, including the initial attempt (`8`, must be `>= 1`); ignored when `failover=off` |
48//! | `failover_backoff_initial_ms`  | first post-failure sleep (`50`; `0` disables sleeps); ignored when `failover=off` |
49//! | `failover_backoff_max_ms`      | max backoff between attempts (`1000`); ignored when `failover=off` |
50//! | `username`         | basic auth                                               |
51//! | `password`         | basic auth                                               |
52//! | `token`            | OIDC access token or QuestDB REST token — sent as `Bearer <token>` |
53//! | `auth`             | verbatim Authorization value                             |
54//! | `tls_verify`       | `on`/`unsafe_off` (`on`)                                 |
55//! | `tls_ca`           | `webpki_roots` / `os_roots` / `webpki_and_os_roots` / `pem_file` (depends on enabled features) |
56//! | `tls_roots`        | path to a PEM bundle, JKS keystore, or PKCS#12 keystore (also implies `tls_ca=pem_file`) |
57//! | `tls_roots_password` | password unlocking the `tls_roots` keystore (JKS / PKCS#12) |
58//!
59//! `tls_roots_password` switches the `tls_roots` file format: with
60//! no password, the file is read as an (unencrypted) PEM bundle —
61//! rustls' native input. With a password set, the file is read as a
62//! Java KeyStore — JKS magic `0xFEEDFEED` or PKCS#12 ASN.1
63//! SEQUENCE — and trusted-certificate entries are extracted, matching
64//! the Java reference client's `tls_roots` / `tls_roots_password`
65//! pair.
66
67use std::path::PathBuf;
68use std::str::FromStr;
69
70use crate::egress::auth::AuthMode;
71use crate::error::{Result, fmt};
72use crate::ingress::CertificateAuthority;
73
74/// Default endpoint path (mirrors the Java client).
75pub const DEFAULT_PATH: &str = "/read/v1";
76
77/// Highest QWP version this client can speak.
78///
79/// QWP runs at a single version, so this is exactly the wire-frame
80/// [`PROTOCOL_VERSION`]: the value advertised in the `X-QWP-Max-Version`
81/// handshake header must equal the version byte
82/// [`FrameHeader::parse`](crate::egress::wire::FrameHeader::parse) accepts,
83/// or the handshake would settle on a version that every subsequent frame
84/// then fails to parse. Defined in terms of the wire constant so bumping
85/// the protocol version moves both together.
86///
87/// [`PROTOCOL_VERSION`]: crate::egress::wire::PROTOCOL_VERSION
88pub const HIGHEST_KNOWN_VERSION: u8 = crate::egress::wire::PROTOCOL_VERSION;
89
90/// Default WS port (matches QuestDB HTTP / ILP-HTTP convention).
91const DEFAULT_PLAIN_PORT: &str = "9000";
92const DEFAULT_TLS_PORT: &str = "9000";
93
94/// Compression negotiation vocabulary.
95///
96/// Drives the `X-QWP-Accept-Encoding` header the client sends on the
97/// WebSocket upgrade ([`Self::header_token`] returns the wire token).
98/// The server picks one codec from the advertised set and echoes its
99/// choice back in `X-QWP-Content-Encoding`; subsequent `RESULT_BATCH`
100/// frames are tagged with `FLAG_ZSTD` (or not) accordingly.
101///
102/// All three variants are usable end-to-end when the client is built
103/// with the `sync-reader-zstd` feature (which `almost-all-features`
104/// turns on by default). Without that feature, `Zstd` / `Auto` still
105/// compile but the decoder rejects any `FLAG_ZSTD` batch the server
106/// sends back with [`ErrorCode::UnsupportedServer`] — surface the
107/// error to the operator rather than silently mis-decoding a
108/// compressed payload as raw wire bytes.
109///
110/// [`ErrorCode::UnsupportedServer`]: crate::ErrorCode::UnsupportedServer
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112#[non_exhaustive]
113pub enum Compression {
114    /// Advertise `raw` only — every `RESULT_BATCH` body is
115    /// uncompressed wire bytes. Works on every client build (no
116    /// `sync-reader-zstd` dependency).
117    Raw,
118    /// Advertise `zstd` only — the server must send compressed
119    /// batches and the client must be built with the
120    /// `sync-reader-zstd` feature to decode them.
121    Zstd,
122    /// Advertise both `zstd,raw` — the server picks. The decoder
123    /// handles either path. If the client was built without the
124    /// `sync-reader-zstd` feature and the server still selects
125    /// `zstd`, the decoder rejects the first `FLAG_ZSTD` batch with
126    /// `UnsupportedServer`; the operator's recovery is to enable the
127    /// feature or pin `Compression::Raw`.
128    Auto,
129}
130
131impl Compression {
132    /// Wire value for the `X-QWP-Accept-Encoding` header. Wire-egress.md
133    /// §3: `zstd` carries an optional `level=N` hint that the server
134    /// clamps to `[1, 9]`; `raw` has no parameters. `Auto` advertises
135    /// `zstd;level=N,raw` (first match wins, per spec). `level` is
136    /// ignored for `Raw`.
137    pub fn accept_encoding(self, level: u8) -> String {
138        match self {
139            Compression::Raw => "raw".to_string(),
140            Compression::Zstd => format!("zstd;level={}", level),
141            Compression::Auto => format!("zstd;level={},raw", level),
142        }
143    }
144
145    /// Bare codec token without the `level=` parameter — useful for
146    /// diagnostics and the (now-rare) callers that want to log just
147    /// "raw" / "zstd" / "zstd,raw". The on-wire value the client
148    /// actually advertises is built by [`Self::accept_encoding`].
149    pub fn header_token(self) -> &'static str {
150        match self {
151            Compression::Raw => "raw",
152            Compression::Zstd => "zstd",
153            Compression::Auto => "zstd,raw",
154        }
155    }
156}
157
158/// Server-routing target hint. Drives both connect-time endpoint walking
159/// and mid-query failover endpoint selection.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161#[non_exhaustive]
162pub enum Target {
163    /// Accept any endpoint, regardless of role. The default.
164    Any,
165    /// Connect only to endpoints whose `SERVER_INFO.role` is
166    /// `PRIMARY`, `PRIMARY_CATCHUP`, or `STANDALONE` (single-node
167    /// OSS counts as PRIMARY per the Java reference). Suitable for
168    /// followers that must observe a writer's perspective.
169    Primary,
170    /// Connect only to endpoints whose `SERVER_INFO.role` is
171    /// `REPLICA`. Suitable for read-scaling clients that prefer
172    /// followers and tolerate replication lag.
173    Replica,
174}
175
176/// A `host:port` endpoint as parsed from a connect string. Used in
177/// the [`ReaderConfig::addrs`] list and surfaced to user code via
178/// [`crate::egress::FailoverResetEvent`] and [`crate::egress::Reader::current_addr`].
179///
180/// Named struct (rather than a `(String, u16)` tuple) so callers can
181/// write `ev.failed_addr.host` / `ep.port` instead of the opaque `.0`
182/// / `.1` accessors. Cheap to clone (small `String` plus `u16`); the
183/// few hot paths that build many of these per failover go through
184/// the underlying `Vec<Endpoint>` directly to avoid extra clones.
185///
186/// `#[non_exhaustive]` so future fields (e.g. a TLS-SNI override or a
187/// resolved-`SocketAddr` cache) can be added without breaking
188/// downstream struct-literal construction or exhaustive destructuring.
189/// Use [`Endpoint::new`] to construct from external code.
190#[derive(Debug, Clone, PartialEq, Eq, Hash)]
191#[non_exhaustive]
192pub struct Endpoint {
193    /// Host portion of the endpoint. Stored verbatim from the
194    /// connect string — no DNS resolution. For IPv6 literals this
195    /// is the bare address (`"::1"`), not the bracketed form;
196    /// [`Display`](std::fmt::Display) re-introduces brackets when
197    /// the host contains a `:`.
198    pub host: String,
199    /// TCP port. The connect-string parser defaults this to `9000`
200    /// for both `ws://` and `wss://` schemes if the address omits
201    /// `:<port>`.
202    pub port: u16,
203}
204
205impl Endpoint {
206    /// Construct an endpoint from any string-like host and a port.
207    ///
208    /// The host is taken verbatim — no DNS resolution. For IPv6
209    /// literals pass the bare address (`"::1"`), not the bracketed form
210    /// (`"[::1]"`); [`Display`](std::fmt::Display) re-introduces brackets
211    /// when formatting any host that contains `:`. The connect-string
212    /// parser strips brackets in the same way, so an `addr=[::1]:9000`
213    /// entry stores `host = "::1"`.
214    pub fn new<S: Into<String>>(host: S, port: u16) -> Self {
215        Endpoint {
216            host: host.into(),
217            port,
218        }
219    }
220}
221
222/// Format as `host:port`. Hosts that contain a `:` (IPv6 literals)
223/// are bracketed — `[::1]:9000` — so the output round-trips
224/// unambiguously through the standard authority-component grammar
225/// (RFC 3986 §3.2.2). Hostnames, IPv4 literals, and any host without
226/// a colon format unbracketed for the common case.
227impl std::fmt::Display for Endpoint {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        if self.host.contains(':') {
230            write!(f, "[{}]:{}", self.host, self.port)
231        } else {
232            write!(f, "{}:{}", self.host, self.port)
233        }
234    }
235}
236
237// ---------------------------------------------------------------------------
238// Default failover knobs. Match the Java `QwpQueryClient` reference
239// (`DEFAULT_FAILOVER_*` constants) so connect strings behave the same
240// in either client.
241// ---------------------------------------------------------------------------
242
243/// Failover-on by default: a connect string that doesn't say
244/// `failover=off` retries transport-class failures across the
245/// configured `addr=` list.
246pub const DEFAULT_FAILOVER_ENABLED: bool = true;
247
248/// Default cap on the number of `connect_endpoint` attempts per
249/// `Execute()`-driven failover round before the cursor surfaces a
250/// terminal error. Capped by [`MAX_FAILOVER_MAX_ATTEMPTS`].
251pub const DEFAULT_FAILOVER_MAX_ATTEMPTS: u32 = 8;
252
253/// Default initial backoff (milliseconds) before the first
254/// failover retry. Per failover.md §3.1 the actual sleep is drawn
255/// uniformly from `[0, base)` (full jitter); this value is the
256/// `base` for attempt 1. Capped by [`MAX_FAILOVER_BACKOFF_MAX_MS`].
257pub const DEFAULT_FAILOVER_BACKOFF_INITIAL_MS: u64 = 50;
258
259/// Default upper bound (milliseconds) on the per-attempt backoff
260/// `base` after exponential growth. Beyond this the schedule
261/// saturates rather than doubling further. Capped by
262/// [`MAX_FAILOVER_BACKOFF_MAX_MS`].
263pub const DEFAULT_FAILOVER_BACKOFF_MAX_MS: u64 = 1_000;
264
265/// Hard upper bound on `failover_max_attempts`. Defensive: at the
266/// minute-scale this is far past where extending the retry budget
267/// stops being useful, and combined with [`MAX_ADDRS`] it bounds the
268/// worst-case dial count and wall-clock the failover cycle can
269/// consume. With `walk_via_tracker` doing at most
270/// `addr_count × 2` picks per outer attempt (the round-attempted
271/// walk plus one fall-through reset walk per failover.md §11.9.3),
272/// the dial ceiling per `next_batch` is
273/// `(MAX_FAILOVER_MAX_ATTEMPTS + 1) × MAX_ADDRS × 2 ≈ 2.1M`. Java
274/// doesn't cap explicitly; this cap is well above any realistic
275/// config.
276pub const MAX_FAILOVER_MAX_ATTEMPTS: u32 = 1024;
277
278/// Hard upper bound on the parsed address-list length. Real connect
279/// strings target a single cluster (a handful of endpoints); this
280/// cap exists so the host-health tracker's per-host state arrays
281/// (state × zone × host classification bits) and the
282/// `walk_via_tracker` dial budget per outer failover attempt stay
283/// bounded by a constant rather than user input. Combined with
284/// [`MAX_FAILOVER_MAX_ATTEMPTS`] it pins the worst-case behaviour of
285/// the whole failover cycle — see that constant's docstring for the
286/// arithmetic.
287pub const MAX_ADDRS: usize = 1024;
288
289/// Hard upper bound on `failover_backoff_max_ms`. Caps a misconfigured
290/// connect string from issuing multi-hour `thread::sleep` calls
291/// during a failover storm. One hour is far past any operationally
292/// useful backoff — beyond this, the user wants application-level
293/// circuit breaking, not transport-level retry.
294pub const MAX_FAILOVER_BACKOFF_MAX_MS: u64 = 60 * 60 * 1_000;
295
296/// Default per-host upper bound on the **HTTP upgrade response read**
297/// during connect, in milliseconds. Failover.md §1.1 default. Catches
298/// the "TCP accepts but the server never replies" blackhole that the
299/// OS connect timeout misses. Does NOT cover TCP connect or TLS
300/// handshake (those use the OS default).
301pub const DEFAULT_AUTH_TIMEOUT_MS: u64 = 15_000;
302
303/// Hard upper bound on `auth_timeout_ms`. One hour is far past any
304/// realistic upgrade-response wait; beyond it the user is using the
305/// knob for something other than its documented purpose.
306pub const MAX_AUTH_TIMEOUT_MS: u64 = 60 * 60 * 1_000;
307
308/// Default per-host upper bound on the **post-upgrade `SERVER_INFO`
309/// frame read**, in milliseconds. Failover.md §1.1 calls for a
310/// separate hard-coded 5 s budget on this frame alone (distinct from
311/// `auth_timeout_ms`, which covers only the HTTP upgrade response).
312/// Matches the Java reference's `DEFAULT_SERVER_INFO_TIMEOUT_MS`.
313///
314/// The frame is short (≤ ~64 KiB), and the server is supposed to
315/// write it into the same kernel send buffer as the upgrade response,
316/// so on a healthy connection the frame is already in the client's
317/// recv buffer by the time this wait starts.
318pub const DEFAULT_SERVER_INFO_TIMEOUT_MS: u64 = 5_000;
319
320/// Hard upper bound on `server_info_timeout_ms`. Same hour cap as
321/// `auth_timeout_ms` — beyond it the user is misusing the knob.
322pub const MAX_SERVER_INFO_TIMEOUT_MS: u64 = 60 * 60 * 1_000;
323
324/// Default wall-clock budget per `Execute()`-driven failover round,
325/// in milliseconds. Failover.md §11.9.1 / §7. `0` means unbounded.
326pub const DEFAULT_FAILOVER_MAX_DURATION_MS: u64 = 30_000;
327
328/// Hard upper bound on `failover_max_duration_ms`. Same hour cap as
329/// `failover_backoff_max_ms` — beyond it the user wants application
330/// circuit breaking, not transport retry.
331pub const MAX_FAILOVER_MAX_DURATION_MS: u64 = 60 * 60 * 1_000;
332
333/// Hard upper bound on `connect_timeout` (the per-endpoint TCP dial
334/// budget), in milliseconds. Same 1-hour cap as the other timeouts;
335/// beyond it the user wants application-level circuit breaking, not a
336/// single dial that pins a thread for an hour.
337pub const MAX_CONNECT_TIMEOUT_MS: u64 = 60 * 60 * 1_000;
338
339/// Default `zstd` compression level advertised in `X-QWP-Accept-Encoding`
340/// as `zstd;level=N`.
341///
342/// This controls the **server-side** encoder: the server honors the
343/// client's requested level when emitting response batches, clamping
344/// anything outside `[1, 9]` into that range (so e.g. level 22 lands
345/// as 9 on the wire). Client-side decode cost is essentially
346/// level-independent, so the trade-off is server CPU per batch vs.
347/// payload size on the wire.
348///
349/// We **diverge from the Java reference** (`compression_level=N`,
350/// default 3) and ship `1` here: level 1 cuts per-batch encoder CPU
351/// substantially with only a modest hit to compression ratio, which is
352/// the better default for the QuestDB workloads we care about. Users who
353/// care more about bytes-on-wire can opt back in with
354/// `compression_level=3` (or anything up to 9 on the effective range).
355pub const DEFAULT_COMPRESSION_LEVEL: u8 = 1;
356
357/// Minimum accepted `compression_level`. Matches zstd's documented range
358/// and the Java reference. `0` is rejected because the spec uses absence
359/// (not zero) to mean "server default".
360pub const MIN_COMPRESSION_LEVEL: u8 = 1;
361
362/// Maximum accepted `compression_level`. zstd's documented maximum and
363/// the Java reference upper bound. The server still clamps to `[1, 9]`
364/// per wire-egress.md §3 — anything higher is a user-side hint that the
365/// server is free to ignore.
366pub const MAX_COMPRESSION_LEVEL: u8 = 22;
367
368/// TLS verification policy.
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370#[non_exhaustive]
371pub enum TlsVerify {
372    On,
373    /// Insecure-skip-verify; only honoured when the `insecure-skip-verify`
374    /// crate feature is enabled.
375    UnsafeOff,
376}
377
378/// Fully validated reader configuration.
379///
380/// Marked `#[non_exhaustive]` so future config knobs (and there will
381/// be more — the failover/auth/TLS surfaces are still maturing) can
382/// be added without breaking downstream code that pattern-matches
383/// or struct-literals this type. Construct via [`Self::from_conf`].
384///
385/// # Validate-before-use contract
386///
387/// The non-`addrs` fields are deliberately `pub` so callers can tweak a
388/// parsed config before handing it to
389/// [`Reader::from_config`](crate::egress::Reader::from_config) (e.g. raise
390/// `failover_max_attempts` for a slow-network test, swap in a different
391/// `client_id`). `#[non_exhaustive]` blocks struct-literal construction
392/// outside this crate but **does not** block field mutation, so a caller
393/// can set `failover_backoff_max_ms = u64::MAX` after parse and bypass
394/// the parse-time hard caps.
395///
396/// The invariant is therefore: **every code path that reads these fields
397/// must run against a `ReaderConfig` that has passed [`Self::validate`]
398/// since its last mutation.** `Reader::from_config` calls `validate` once,
399/// defensively, before opening any socket — relying on that is the
400/// supported path. If you mutate fields after `Reader::from_config` has
401/// returned (or share an `&mut ReaderConfig` across threads in a way
402/// that's hard to reason about), call `validate()` again yourself before
403/// re-using the config.
404///
405/// `addrs` is `pub(crate)` to keep external code from mutating the
406/// address list once a `Reader` is built around an `Arc<ReaderConfig>`
407/// snapshot; read-only access is via [`Self::addrs`].
408#[derive(Debug, Clone)]
409#[non_exhaustive]
410pub struct ReaderConfig {
411    /// Endpoints to walk on connect, in order. The Reader tries each
412    /// until one accepts the WS handshake and advertises a role matching
413    /// `target`.
414    ///
415    /// Crate-private to keep external code from mutating the address
416    /// list after a `Reader` has been built around an `Arc<ReaderConfig>`
417    /// snapshot. Read-only access is via [`Self::addrs`].
418    pub(crate) addrs: Vec<Endpoint>,
419    pub tls: bool,
420    pub path: String,
421    pub max_version: u8,
422    pub compression: Compression,
423    /// `zstd;level=N` hint advertised in `X-QWP-Accept-Encoding` when
424    /// [`compression`](Self::compression) is `Zstd` or `Auto`. Ignored
425    /// for `Raw`. Range `[MIN_COMPRESSION_LEVEL, MAX_COMPRESSION_LEVEL]`;
426    /// the server clamps to `[1, 9]` per wire-egress.md §3. Default
427    /// [`DEFAULT_COMPRESSION_LEVEL`] (= 1).
428    pub compression_level: u8,
429    pub max_batch_rows: u64,
430    pub client_id: Option<String>,
431    pub target: Target,
432    /// Mid-query failover. When `true` and the transport fails after a
433    /// `QUERY_REQUEST` has been submitted, the cursor reconnects to the
434    /// next endpoint (rotating, skipping the failed one first), replays
435    /// the query with a fresh `request_id`, and resumes from `batch_seq=0`
436    /// on the new connection. The user-side handler must reset any
437    /// accumulated rows when notified via the
438    /// [`ReaderQuery::on_failover_reset`](crate::egress::ReaderQuery::on_failover_reset)
439    /// callback.
440    ///
441    /// When `false`, the `failover_*` tunables below are accepted by
442    /// the parser (so configs aren't rejected on a partial enable/disable
443    /// flip) but have no effect — transport failures surface immediately.
444    pub failover: bool,
445    /// Cap on total `Execute()` attempts for one query (default `8`):
446    /// the initial attempt plus at most `failover_max_attempts - 1`
447    /// reconnect/replay rounds. Must be `>= 1`. Ignored when
448    /// [`failover`](Self::failover) is `false`.
449    pub failover_max_attempts: u32,
450    /// First post-failure sleep, in milliseconds. `0` disables
451    /// failover sleeps entirely.
452    /// Ignored when [`failover`](Self::failover) is `false`.
453    pub failover_backoff_initial_ms: u64,
454    /// Maximum (capped) backoff between failover attempts, in milliseconds.
455    /// Ignored when [`failover`](Self::failover) is `false`.
456    pub failover_backoff_max_ms: u64,
457    /// Wall-clock budget per `Execute()`, in milliseconds. `0` means
458    /// unbounded. Bounds failover eligibility, not total Execute
459    /// wall-clock — a single `WalkTracker` round can run up to
460    /// `host_count × auth_timeout_ms` after the deadline check passes.
461    /// Failover.md §11.9.1 / §7.
462    ///
463    /// Ignored when [`failover`](Self::failover) is `false`.
464    pub failover_max_duration_ms: u64,
465    /// Per-host upper bound on the WS upgrade-response read, in
466    /// milliseconds. Bounds the "TCP accepts but server never replies"
467    /// blackhole that the OS connect timeout misses. Does NOT cover TCP
468    /// connect, TLS handshake, or the post-upgrade `SERVER_INFO` frame
469    /// read (those use the OS default / [`Self::server_info_timeout_ms`]
470    /// respectively). Failover.md §1.1.
471    pub auth_timeout_ms: u64,
472    /// Per-host upper bound on the post-upgrade `SERVER_INFO` (`0x18`)
473    /// frame read, in milliseconds. Bounds the case where the server
474    /// accepts the WS upgrade (HTTP 101) but never sends the
475    /// `SERVER_INFO` binary frame — without this, the connect would
476    /// stall indefinitely after `auth_timeout_ms` has already passed.
477    /// Failover.md §1.1 specifies a separate 5 s budget; the knob is
478    /// programmatic-only (not a connect-string key) so it tracks the
479    /// Java reference's `withServerInfoTimeout` surface.
480    pub server_info_timeout_ms: u64,
481    /// Per-endpoint TCP connect (dial) budget, in milliseconds. `0`
482    /// (the default) means "no client-imposed connect timeout": the dial
483    /// uses the OS default, which can hang for tens of seconds against a
484    /// black-holed host that silently drops SYNs. When `> 0`, each dial is
485    /// a `TcpStream::connect_timeout` bounded by this value (per resolved
486    /// address); exceeding it surfaces [`crate::ErrorCode::ConnectTimeout`] and,
487    /// under failover, advances to the next endpoint. Connect-string key:
488    /// `connect_timeout`. Does NOT bound name resolution, the TLS
489    /// handshake, the WS upgrade (see `auth_timeout_ms`), or the
490    /// `SERVER_INFO` read (see `server_info_timeout_ms`).
491    pub connect_timeout_ms: u64,
492    /// Client's zone identifier — opaque case-insensitive string (e.g.
493    /// `eu-west-1a`, `dc-amsterdam`). When set, the host-health tracker
494    /// prefers endpoints whose server-advertised `zone_id` matches
495    /// (`SERVER_INFO.zone_id` gated on `CAP_ZONE`, or `X-QuestDB-Zone`
496    /// header on a `421` reject). `None` collapses every host's zone tier
497    /// to `Same` (zone-blind selection). `target=primary` likewise
498    /// collapses tiers to `Same` regardless of this value — writers
499    /// follow the master across zones. Failover.md §1.1 / §2.
500    pub zone: Option<String>,
501    pub auth: AuthMode,
502    pub tls_verify: TlsVerify,
503    pub tls_ca: CertificateAuthority,
504    pub tls_roots: Option<PathBuf>,
505    /// Password unlocking the `tls_roots` keystore.
506    ///
507    /// When set, `tls_roots` is interpreted as a JKS or PKCS#12
508    /// keystore (auto-detected by magic) rather than a PEM bundle.
509    /// Trusted-certificate entries are extracted into the rustls
510    /// root store; private-key entries are ignored — this is a
511    /// trust store, not a client-identity store. Mirrors the Java
512    /// reference's `KeyStore.getInstance(...).load(stream, pwd)`
513    /// flow.
514    pub tls_roots_password: Option<String>,
515}
516
517/// Connect-string keys that the Rust ingress sender
518/// (`crate::ingress::SenderBuilder::from_conf`) recognizes but the
519/// egress reader has no use for. Silently accepted so a single connect
520/// string can be shared between a sender and a reader process; new
521/// ingress keys MUST be added here or the cross-role portability
522/// guarantee drifts.
523///
524/// Shared keys (`addr`, `username`, `password`, `token`, `tls_verify`,
525/// `tls_ca`, `tls_roots`, `tls_roots_password`, `auth_timeout_ms`,
526/// `zone`) are NOT listed here — both parsers have explicit arms.
527pub(crate) const INGRESS_ONLY_CONFIG_KEYS: &[&str] = &[
528    // ILP TCP auth
529    "token_x",
530    "token_y",
531    // ILP transport
532    "bind_interface",
533    // ILP UDP
534    "max_datagram_size",
535    "multicast_ttl",
536    // ILP auto-flush (sender accumulates rows; reader is pull-based)
537    "auto_flush",
538    "auto_flush_rows",
539    "auto_flush_bytes",
540    "auto_flush_interval",
541    // ILP buffer sizing & wire-protocol selection
542    "init_buf_size",
543    "max_buf_size",
544    "max_name_len",
545    "protocol_version",
546    // ILP HTTP transport
547    "request_min_throughput",
548    "request_timeout",
549    "retry_timeout",
550    "retry_max_backoff_millis",
551    // Generic auth timeout (Duration in millis; distinct from the
552    // shared `auth_timeout_ms` which is QWP-WS-specific on ingress).
553    "auth_timeout",
554    // QWP-WS pacing
555    "qwp_ws_progress",
556    "max_frame_rejections",
557    "poison_min_escalation_window_millis",
558    // QWP-WS store-and-forward
559    "sf_dir",
560    "sender_id",
561    "sf_max_segment_bytes",
562    "sf_max_total_bytes",
563    "sf_durability",
564    "sf_sync_interval_millis",
565    "sf_append_deadline_millis",
566    // QWP-WS reconnect / connect retry
567    "reconnect_max_duration_millis",
568    "reconnect_initial_backoff_millis",
569    "reconnect_max_backoff_millis",
570    "initial_connect_retry",
571    // QWP-WS lifecycle / observability
572    "close_flush_timeout_millis",
573    "request_durable_ack",
574    "durable_ack_keepalive_interval_millis",
575    "drain_orphans",
576    "max_background_drainers",
577    "error_inbox_capacity",
578    // Connection-pool knobs owned by `questdb_db` (the column-sender
579    // pool). The reader doesn't pool itself — `questdb_db` pools
580    // readers on the reader's behalf — but a Client that holds both
581    // a sender and a reader pool is configured by one conf-string,
582    // so the reader's parser accepts and ignores these.
583    "sender_pool_min",
584    "sender_pool_max",
585    "query_pool_min",
586    "query_pool_max",
587    "acquire_timeout_ms",
588    "idle_timeout_ms",
589    "lazy_connect",
590    "pool_reap",
591];
592
593impl ReaderConfig {
594    /// Construct from a connect-string.
595    pub fn from_conf<T: AsRef<str>>(conf: T) -> Result<Self> {
596        let conf_str = conf.as_ref();
597
598        // Pre-scan the raw conf to collect every `addr=...` param. This
599        // accepts both the comma-separated form (`addr=h1,h2,...`) and the
600        // repeated-key form (`addr=h1;addr=h2;...`), matching the ingress
601        // multi-host parser. The sanitized conf string has duplicate
602        // `addr=` params removed so the standard `questdb_confstr` parser
603        // doesn't see them twice.
604        // The scan helper is shared with ingress and already returns the
605        // crate-wide `Error` type. Add reader-specific context here. The only
606        // failure mode is a malformed conf, which the helper actually signals as
607        // `Ok(None)` rather than `Err`, so the remap is defensive.
608        let addr_scan = crate::ingress::scan_qwp_ws_addr_params(conf_str)
609            .map_err(|e| fmt!(ConfigError, "{}", e.msg()))?;
610        let conf_to_parse = addr_scan
611            .as_ref()
612            .map(|s| s.sanitized_conf.as_str())
613            .unwrap_or(conf_str);
614
615        let conf = questdb_confstr::parse_conf_str(conf_to_parse)
616            .map_err(|e| fmt!(ConfigError, "Config parse error: {}", e))?;
617        let scheme = conf.service();
618        let tls = match scheme {
619            "ws" => false,
620            "wss" => true,
621            other => {
622                return Err(fmt!(
623                    ConfigError,
624                    "Unknown scheme \"{}\" — expected \"ws\" or \"wss\"",
625                    other
626                ));
627            }
628        };
629        let params = conf.params();
630
631        // Required: addr (single `host[:port]`, comma-separated list, or
632        // repeated-key form). `addr_scan.is_some()` is guaranteed because
633        // the scheme passed the ws/wss check above, but fall back to a
634        // single `params.get("addr")` lookup defensively.
635        let addr_values: Vec<&str> = match &addr_scan {
636            Some(s) if !s.addr_values.is_empty() => {
637                s.addr_values.iter().map(String::as_str).collect()
638            }
639            _ => {
640                let addr = params.get("addr").ok_or_else(|| {
641                    fmt!(ConfigError, "Missing \"addr\" parameter in config string")
642                })?;
643                vec![addr.as_str()]
644            }
645        };
646
647        let default_port = if tls {
648            DEFAULT_TLS_PORT
649        } else {
650            DEFAULT_PLAIN_PORT
651        };
652        let mut addrs: Vec<Endpoint> = Vec::new();
653        let mut i: usize = 0;
654        for addr in addr_values {
655            for entry in addr.split(',').map(str::trim) {
656                if entry.is_empty() {
657                    return Err(fmt!(ConfigError, "Empty entry {} in \"addr\" list", i));
658                }
659                // IPv6 literals must be bracketed per RFC 3986 §3.2.2 to
660                // disambiguate the authority's port colon from the address's
661                // own colons. Strip the brackets here so the canonical stored
662                // form is bare; `Endpoint::Display` re-introduces them when
663                // formatting any host that contains `:`. Without this the
664                // brackets get re-applied on top of the stored ones,
665                // producing `ws://[[::1]]:9000/...` and a URL parse error.
666                let (host, port_str) = if let Some(rest) = entry.strip_prefix('[') {
667                    let close = rest.find(']').ok_or_else(|| {
668                        fmt!(
669                            ConfigError,
670                            "Bracketed addr entry {} missing closing ']': {:?}",
671                            i,
672                            entry
673                        )
674                    })?;
675                    let host = rest[..close].to_string();
676                    let after = &rest[close + 1..];
677                    let port_str = if after.is_empty() {
678                        default_port.to_string()
679                    } else if let Some(p) = after.strip_prefix(':') {
680                        p.to_string()
681                    } else {
682                        return Err(fmt!(
683                            ConfigError,
684                            "Unexpected characters after ']' in addr entry {}: {:?}",
685                            i,
686                            entry
687                        ));
688                    };
689                    (host, port_str)
690                } else {
691                    // Reject unbracketed multi-colon entries. `rsplit_once(':')`
692                    // would otherwise treat `::1` as host=`::`, port=`1` and
693                    // `2001:db8::1` as host=`2001:db8:`, port=`1` — surprising
694                    // misparses for users who omit the required brackets on an
695                    // IPv6 literal.
696                    if entry.bytes().filter(|&b| b == b':').count() > 1 {
697                        return Err(fmt!(
698                            ConfigError,
699                            "addr entry {} contains multiple ':' — IPv6 literals \
700                         must be bracketed (e.g. [::1]:9000): {:?}",
701                            i,
702                            entry
703                        ));
704                    }
705                    match entry.rsplit_once(':') {
706                        Some((h, p)) => (h.to_string(), p.to_string()),
707                        None => (entry.to_string(), default_port.to_string()),
708                    }
709                };
710                if host.is_empty() {
711                    return Err(fmt!(
712                        ConfigError,
713                        "Empty host in \"addr\" entry {}: {:?}",
714                        i,
715                        entry
716                    ));
717                }
718                let port: u16 = port_str.parse().map_err(|_| {
719                    fmt!(
720                        ConfigError,
721                        "Invalid port in \"addr\" entry {}: {:?}",
722                        i,
723                        entry
724                    )
725                })?;
726                // Port 0 is the "ephemeral pick" sentinel for *listeners*;
727                // for an outbound connect target it's meaningless. The
728                // kernel rejects it as `EADDRNOTAVAIL` / `ECONNREFUSED`,
729                // which the egress code would surface as a `SocketError`
730                // — but with a confusing message ("connection refused")
731                // that hides the actual misconfiguration. Reject at parse
732                // so the diagnostic names the real cause.
733                if port == 0 {
734                    return Err(fmt!(
735                        ConfigError,
736                        "Port 0 is not a valid connect target in \"addr\" entry {}: {:?}",
737                        i,
738                        entry
739                    ));
740                }
741                addrs.push(Endpoint { host, port });
742                i += 1;
743            }
744        }
745        if addrs.is_empty() {
746            return Err(fmt!(ConfigError, "\"addr\" parameter is empty"));
747        }
748        if addrs.len() > MAX_ADDRS {
749            return Err(fmt!(
750                ConfigError,
751                "\"addr\" list length {} exceeds the hard cap of {}",
752                addrs.len(),
753                MAX_ADDRS
754            ));
755        }
756
757        // Optional / typed
758        let mut path: String = DEFAULT_PATH.to_string();
759        let mut max_version: u8 = HIGHEST_KNOWN_VERSION;
760        let mut compression = Compression::Raw;
761        let mut compression_level: u8 = DEFAULT_COMPRESSION_LEVEL;
762        let mut max_batch_rows: u64 = 0;
763        let mut client_id: Option<String> = None;
764        let mut target = Target::Any;
765        let mut failover = DEFAULT_FAILOVER_ENABLED;
766        let mut failover_max_attempts: u32 = DEFAULT_FAILOVER_MAX_ATTEMPTS;
767        let mut failover_backoff_initial_ms: u64 = DEFAULT_FAILOVER_BACKOFF_INITIAL_MS;
768        let mut failover_backoff_max_ms: u64 = DEFAULT_FAILOVER_BACKOFF_MAX_MS;
769        let mut failover_max_duration_ms: u64 = DEFAULT_FAILOVER_MAX_DURATION_MS;
770        let mut auth_timeout_ms: u64 = DEFAULT_AUTH_TIMEOUT_MS;
771        let server_info_timeout_ms: u64 = DEFAULT_SERVER_INFO_TIMEOUT_MS;
772        let mut connect_timeout_ms: u64 = 0;
773        let mut zone: Option<String> = None;
774        let mut tls_verify = TlsVerify::On;
775        let mut tls_ca = default_tls_ca();
776        let mut tls_ca_explicit = false;
777        let mut tls_roots: Option<PathBuf> = None;
778        let mut tls_roots_password: Option<String> = None;
779
780        let mut username: Option<String> = None;
781        let mut password: Option<String> = None;
782        let mut token: Option<String> = None;
783        let mut auth_verbatim: Option<String> = None;
784
785        for (key, val) in params.iter() {
786            let key = key.as_str();
787            let val = val.as_str();
788            match key {
789                "addr" => {} // already consumed
790                "path" => {
791                    if !val.starts_with('/') {
792                        return Err(fmt!(
793                            ConfigError,
794                            "\"path\" must start with '/' (got {:?})",
795                            val
796                        ));
797                    }
798                    path = val.to_string();
799                }
800                "max_version" => {
801                    let v: u8 = parse_value("max_version", val)?;
802                    if !(1..=HIGHEST_KNOWN_VERSION).contains(&v) {
803                        return Err(fmt!(
804                            ConfigError,
805                            "\"max_version\" must be in 1..={} (got {})",
806                            HIGHEST_KNOWN_VERSION,
807                            v
808                        ));
809                    }
810                    max_version = v;
811                }
812                "compression" => {
813                    compression = match val {
814                        "raw" => Compression::Raw,
815                        "zstd" => Compression::Zstd,
816                        "auto" => Compression::Auto,
817                        other => {
818                            return Err(fmt!(
819                                ConfigError,
820                                "\"compression\" must be one of raw|zstd|auto (got {:?})",
821                                other
822                            ));
823                        }
824                    };
825                }
826                "compression_level" => {
827                    let v: u8 = parse_value("compression_level", val)?;
828                    if !(MIN_COMPRESSION_LEVEL..=MAX_COMPRESSION_LEVEL).contains(&v) {
829                        return Err(fmt!(
830                            ConfigError,
831                            "\"compression_level\" must be in {}..={} (got {})",
832                            MIN_COMPRESSION_LEVEL,
833                            MAX_COMPRESSION_LEVEL,
834                            v
835                        ));
836                    }
837                    compression_level = v;
838                }
839                "max_batch_rows" => {
840                    max_batch_rows = parse_value("max_batch_rows", val)?;
841                }
842                "client_id" => {
843                    reject_crlf("client_id", val)?;
844                    client_id = Some(val.to_string());
845                }
846                "target" => {
847                    target = match val {
848                        "any" => Target::Any,
849                        "primary" => Target::Primary,
850                        "replica" => Target::Replica,
851                        other => {
852                            return Err(fmt!(
853                                ConfigError,
854                                "\"target\" must be one of any|primary|replica (got {:?})",
855                                other
856                            ));
857                        }
858                    };
859                }
860                "username" => username = Some(val.to_string()),
861                "password" => password = Some(val.to_string()),
862                "token" => token = Some(val.to_string()),
863                "auth" => auth_verbatim = Some(val.to_string()),
864                "tls_verify" => {
865                    tls_verify = match val {
866                        "on" => TlsVerify::On,
867                        "unsafe_off" => TlsVerify::UnsafeOff,
868                        other => {
869                            return Err(fmt!(
870                                ConfigError,
871                                "\"tls_verify\" must be \"on\" or \"unsafe_off\" (got {:?})",
872                                other
873                            ));
874                        }
875                    };
876                }
877                "tls_ca" => {
878                    tls_ca = parse_tls_ca(val)?;
879                    tls_ca_explicit = true;
880                }
881                "tls_roots" => {
882                    let path = PathBuf::from_str(val).map_err(|e| {
883                        fmt!(
884                            ConfigError,
885                            "Invalid path for \"tls_roots\" ({:?}): {}",
886                            val,
887                            e
888                        )
889                    })?;
890                    tls_roots = Some(path);
891                }
892                "tls_roots_password" => {
893                    tls_roots_password = Some(val.to_string());
894                }
895
896                "failover" => {
897                    failover = parse_bool("failover", val)?;
898                }
899                "failover_max_attempts" => {
900                    failover_max_attempts = parse_value("failover_max_attempts", val)?;
901                }
902                "failover_backoff_initial_ms" => {
903                    failover_backoff_initial_ms = parse_value("failover_backoff_initial_ms", val)?;
904                }
905                "failover_backoff_max_ms" => {
906                    failover_backoff_max_ms = parse_value("failover_backoff_max_ms", val)?;
907                }
908                "failover_max_duration_ms" => {
909                    failover_max_duration_ms = parse_value("failover_max_duration_ms", val)?;
910                }
911                "auth_timeout_ms" => {
912                    auth_timeout_ms = parse_value("auth_timeout_ms", val)?;
913                }
914                "connect_timeout" => {
915                    connect_timeout_ms = parse_value("connect_timeout", val)?;
916                }
917                "zone" => {
918                    // Empty / whitespace-only is treated as unset
919                    // (zone-blind). Reject CR/LF — these are headers /
920                    // log values and embedding control bytes risks
921                    // injection downstream.
922                    reject_crlf("zone", val)?;
923                    let trimmed = val.trim();
924                    zone = if trimmed.is_empty() {
925                        None
926                    } else {
927                        Some(trimmed.to_string())
928                    };
929                }
930
931                // Per-category server-error policy knobs reserved by the
932                // QWP spec (see java-questdb-client
933                // design/qwp-cursor-error-api.md). Parsed but ignored so
934                // the same connect string works against clients that have
935                // already wired the policy resolver; the egress reader's
936                // ingest-side error model is independent.
937                "on_server_error" | "on_schema_error" | "on_parse_error" | "on_internal_error"
938                | "on_security_error" | "on_write_error" => {}
939
940                // Java sizes a decoded-batch pool that buffers between
941                // its I/O thread and the user's `onBatch` callback. The
942                // Rust egress is synchronous and pull-based (`next_batch`
943                // reads inline into the cursor's own scratch), so there
944                // is no pool to size. Accept the key without inspecting
945                // the value so a connect string tuned for the Java
946                // client still parses here.
947                "buffer_pool_size" => {}
948
949                // Connect-string portability: keys recognized by the
950                // Rust ingress sender but meaningless to the egress
951                // reader. See the comment on `INGRESS_ONLY_CONFIG_KEYS`
952                // for the rationale and the canonical list. Truly
953                // unknown keys (typos) still hit the error branch
954                // below, so the typo-detection safety net is intact.
955                other if INGRESS_ONLY_CONFIG_KEYS.contains(&other) => {}
956
957                other => {
958                    return Err(fmt!(ConfigError, "Unknown config key \"{}\"", other));
959                }
960            }
961        }
962
963        // zstd / auto require the sync-reader-zstd feature.
964        #[cfg(not(feature = "sync-reader-zstd"))]
965        {
966            if !matches!(compression, Compression::Raw) {
967                let user_token = match compression {
968                    Compression::Raw => "raw",
969                    Compression::Zstd => "zstd",
970                    Compression::Auto => "auto",
971                };
972                return Err(fmt!(
973                    ConfigError,
974                    "\"compression={}\" requires the `sync-reader-zstd` crate feature; \
975                     either enable it or use \"raw\"",
976                    user_token
977                ));
978            }
979        }
980
981        // The `tls_verify=unsafe_off` feature-gate check is enforced by
982        // `validate()` (called below) so a post-parse mutation of
983        // `cfg.tls_verify = TlsVerify::UnsafeOff` is also rejected —
984        // without that, the runtime would silently downgrade to the
985        // default verifier and the caller's explicit "off" intent would
986        // be lost.
987
988        // tls_* knobs only make sense with TLS scheme.
989        if !tls && (tls_roots.is_some() || tls_ca_explicit || tls_roots_password.is_some()) {
990            return Err(fmt!(
991                ConfigError,
992                "TLS-related keys require the \"wss\" scheme"
993            ));
994        }
995
996        // `tls_roots_password` only makes sense paired with `tls_roots`
997        // (the password unlocks the file named there). Java enforces
998        // the same pairing — see `QwpQueryClient.java`'s
999        // "tls_roots and tls_roots_password must be provided together"
1000        // check.
1001        if tls_roots_password.is_some() && tls_roots.is_none() {
1002            return Err(fmt!(
1003                ConfigError,
1004                "\"tls_roots_password\" requires \"tls_roots\" \
1005                 (the password unlocks the keystore at that path)"
1006            ));
1007        }
1008
1009        // `tls_roots=<path>` implies `tls_ca=pem_file` unless the caller
1010        // also explicitly set a different `tls_ca` (in which case we error
1011        // because the combination is contradictory).
1012        if tls_roots.is_some() {
1013            if tls_ca_explicit && tls_ca != CertificateAuthority::PemFile {
1014                return Err(fmt!(
1015                    ConfigError,
1016                    "\"tls_roots\" requires \"tls_ca=pem_file\" (or omit \"tls_ca\")"
1017                ));
1018            }
1019            tls_ca = CertificateAuthority::PemFile;
1020        }
1021
1022        let auth = AuthMode::from_parts(
1023            username.as_deref(),
1024            password.as_deref(),
1025            token.as_deref(),
1026            auth_verbatim.as_deref(),
1027        )?;
1028
1029        let cfg = ReaderConfig {
1030            addrs,
1031            tls,
1032            path,
1033            max_version,
1034            compression,
1035            compression_level,
1036            max_batch_rows,
1037            client_id,
1038            target,
1039            failover,
1040            failover_max_attempts,
1041            failover_backoff_initial_ms,
1042            failover_backoff_max_ms,
1043            failover_max_duration_ms,
1044            auth_timeout_ms,
1045            server_info_timeout_ms,
1046            connect_timeout_ms,
1047            zone,
1048            auth,
1049            tls_verify,
1050            tls_ca,
1051            tls_roots,
1052            tls_roots_password,
1053        };
1054        cfg.validate()?;
1055        Ok(cfg)
1056    }
1057
1058    /// Re-run the cap and consistency checks that `from_conf` enforces.
1059    ///
1060    /// This is the enforcement half of the validate-before-use contract
1061    /// documented on [`ReaderConfig`] itself: `pub` fields keep the
1062    /// config ergonomic to tweak post-parse, and `validate()` is what
1063    /// any reader of those fields can rely on to have run since the
1064    /// last mutation. `Reader::from_config` calls this defensively
1065    /// before opening any socket; call it explicitly after mutating
1066    /// a config you intend to re-use through another entry point.
1067    pub fn validate(&self) -> Result<()> {
1068        if self.addrs.is_empty() {
1069            return Err(fmt!(ConfigError, "\"addr\" parameter is empty"));
1070        }
1071        if self.addrs.len() > MAX_ADDRS {
1072            return Err(fmt!(
1073                ConfigError,
1074                "\"addr\" list length {} exceeds the hard cap of {}",
1075                self.addrs.len(),
1076                MAX_ADDRS
1077            ));
1078        }
1079        if !(1..=HIGHEST_KNOWN_VERSION).contains(&self.max_version) {
1080            return Err(fmt!(
1081                ConfigError,
1082                "\"max_version\" must be in 1..={} (got {})",
1083                HIGHEST_KNOWN_VERSION,
1084                self.max_version
1085            ));
1086        }
1087        if !(MIN_COMPRESSION_LEVEL..=MAX_COMPRESSION_LEVEL).contains(&self.compression_level) {
1088            return Err(fmt!(
1089                ConfigError,
1090                "\"compression_level\" must be in {}..={} (got {})",
1091                MIN_COMPRESSION_LEVEL,
1092                MAX_COMPRESSION_LEVEL,
1093                self.compression_level
1094            ));
1095        }
1096        if self.failover_max_attempts == 0 {
1097            return Err(fmt!(
1098                ConfigError,
1099                "\"failover_max_attempts\" must be >= 1 (use \"failover=off\" to disable failover entirely)"
1100            ));
1101        }
1102        if self.failover_max_attempts > MAX_FAILOVER_MAX_ATTEMPTS {
1103            return Err(fmt!(
1104                ConfigError,
1105                "\"failover_max_attempts\" {} exceeds the hard cap of {}",
1106                self.failover_max_attempts,
1107                MAX_FAILOVER_MAX_ATTEMPTS
1108            ));
1109        }
1110        if self.failover_backoff_max_ms < self.failover_backoff_initial_ms {
1111            return Err(fmt!(
1112                ConfigError,
1113                "\"failover_backoff_max_ms\" ({}) must be >= \"failover_backoff_initial_ms\" ({})",
1114                self.failover_backoff_max_ms,
1115                self.failover_backoff_initial_ms
1116            ));
1117        }
1118        if self.failover_backoff_max_ms > MAX_FAILOVER_BACKOFF_MAX_MS {
1119            return Err(fmt!(
1120                ConfigError,
1121                "\"failover_backoff_max_ms\" {} exceeds the hard cap of {} (1 hour)",
1122                self.failover_backoff_max_ms,
1123                MAX_FAILOVER_BACKOFF_MAX_MS
1124            ));
1125        }
1126        // `failover_max_duration_ms = 0` is the documented "unbounded"
1127        // sentinel — don't reject it. Cap the upper bound the same way
1128        // we cap `failover_backoff_max_ms` so a misconfigured value can't
1129        // pin a thread waiting on failover for days.
1130        if self.failover_max_duration_ms > MAX_FAILOVER_MAX_DURATION_MS {
1131            return Err(fmt!(
1132                ConfigError,
1133                "\"failover_max_duration_ms\" {} exceeds the hard cap of {} (1 hour)",
1134                self.failover_max_duration_ms,
1135                MAX_FAILOVER_MAX_DURATION_MS
1136            ));
1137        }
1138        if self.auth_timeout_ms == 0 {
1139            return Err(fmt!(
1140                ConfigError,
1141                "\"auth_timeout_ms\" must be > 0 (no sentinel for \"unbounded\" — \
1142                 set a value high enough for your slowest peer's upgrade response)"
1143            ));
1144        }
1145        if self.auth_timeout_ms > MAX_AUTH_TIMEOUT_MS {
1146            return Err(fmt!(
1147                ConfigError,
1148                "\"auth_timeout_ms\" {} exceeds the hard cap of {} (1 hour)",
1149                self.auth_timeout_ms,
1150                MAX_AUTH_TIMEOUT_MS
1151            ));
1152        }
1153        if self.server_info_timeout_ms == 0 {
1154            return Err(fmt!(ConfigError, "\"server_info_timeout_ms\" must be > 0"));
1155        }
1156        if self.server_info_timeout_ms > MAX_SERVER_INFO_TIMEOUT_MS {
1157            return Err(fmt!(
1158                ConfigError,
1159                "\"server_info_timeout_ms\" {} exceeds the hard cap of {} (1 hour)",
1160                self.server_info_timeout_ms,
1161                MAX_SERVER_INFO_TIMEOUT_MS
1162            ));
1163        }
1164        // `connect_timeout = 0` is the documented "OS default" sentinel —
1165        // don't reject it. Cap the upper bound so a typo can't pin a dialing
1166        // thread for an hour.
1167        if self.connect_timeout_ms > MAX_CONNECT_TIMEOUT_MS {
1168            return Err(fmt!(
1169                ConfigError,
1170                "\"connect_timeout\" {} exceeds the hard cap of {} (1 hour)",
1171                self.connect_timeout_ms,
1172                MAX_CONNECT_TIMEOUT_MS
1173            ));
1174        }
1175        // String fields & auth aren't covered by the numeric/range
1176        // checks above. Without these re-checks, a caller who built
1177        // `cfg` via `from_conf` (clean) and then mutated `client_id`,
1178        // `zone`, or `auth` (the struct's fields are `pub`) could
1179        // smuggle CRLF / control bytes into the WS upgrade headers
1180        // and inject downstream — `#[non_exhaustive]` blocks struct
1181        // literal construction but does not block field assignment.
1182        if let Some(id) = &self.client_id {
1183            reject_crlf("client_id", id)?;
1184        }
1185        if let Some(z) = &self.zone {
1186            reject_crlf("zone", z)?;
1187        }
1188        self.auth.validate()?;
1189        // tls_verify=unsafe_off needs the crate feature. Re-checked
1190        // here so a post-parse mutation of `cfg.tls_verify =
1191        // TlsVerify::UnsafeOff` is rejected too — the TLS builder
1192        // silently downgrades to the default verifier when the feature
1193        // is off (runtime is safe), so without this check the caller's
1194        // explicit "off" intent would be lost without diagnostic.
1195        #[cfg(not(feature = "insecure-skip-verify"))]
1196        if matches!(self.tls_verify, TlsVerify::UnsafeOff) {
1197            return Err(fmt!(
1198                ConfigError,
1199                "\"tls_verify=unsafe_off\" requires the \"insecure-skip-verify\" crate feature"
1200            ));
1201        }
1202        Ok(())
1203    }
1204
1205    pub(crate) fn failover_reconnect_rounds(&self) -> u32 {
1206        self.failover_max_attempts.saturating_sub(1)
1207    }
1208
1209    /// Read-only view of the parsed endpoint list. The list is populated
1210    /// by [`from_conf`](Self::from_conf) and frozen for the lifetime of
1211    /// the config — this getter is the only public access path.
1212    pub fn addrs(&self) -> &[Endpoint] {
1213        &self.addrs
1214    }
1215
1216    /// Build the URL for the WebSocket upgrade against the endpoint at
1217    /// `idx` in [`addrs`](Self::addrs). Panics if `idx` is out of range.
1218    pub fn url_for(&self, idx: usize) -> String {
1219        let ep = &self.addrs[idx];
1220        let scheme = if self.tls { "wss" } else { "ws" };
1221        // `{ep}` formats as `host:port` (or `[host]:port` for IPv6
1222        // literals), giving an unambiguous URL authority component.
1223        format!("{}://{}{}", scheme, ep, self.path)
1224    }
1225
1226    /// First endpoint URL — convenience for single-addr configs.
1227    pub fn url(&self) -> String {
1228        self.url_for(0)
1229    }
1230
1231    /// Build the negotiation headers as `(name, value)` pairs in the order
1232    /// the Java reference client emits them. Authorization is appended last
1233    /// when an auth mode is set.
1234    pub fn upgrade_headers(&self) -> Vec<(&'static str, String)> {
1235        let mut headers = Vec::with_capacity(8);
1236        headers.push(("X-QWP-Max-Version", self.max_version.to_string()));
1237        if let Some(id) = &self.client_id {
1238            headers.push(("X-QWP-Client-Id", id.clone()));
1239        }
1240        // Always emit accept-encoding so the server knows what we'll handle;
1241        // raw-only today still benefits from being explicit. `level=N` is
1242        // only meaningful for zstd/auto; `Compression::accept_encoding`
1243        // drops it for `Raw`.
1244        headers.push((
1245            "X-QWP-Accept-Encoding",
1246            self.compression.accept_encoding(self.compression_level),
1247        ));
1248        if self.max_batch_rows > 0 {
1249            headers.push(("X-QWP-Max-Batch-Rows", self.max_batch_rows.to_string()));
1250        }
1251        if let Some(v) = self.auth.header_value() {
1252            headers.push(("Authorization", v));
1253        }
1254        headers
1255    }
1256}
1257
1258/// Default `tls_ca` mirrors the ingress sender: prefer webpki roots if
1259/// the bundled-certs feature is on, fall back to OS roots, and finally
1260/// to `pem_file` (which forces the user to supply `tls_roots`). Keeps
1261/// `wss://` working out of the box on the common feature combos.
1262fn default_tls_ca() -> CertificateAuthority {
1263    #[cfg(feature = "tls-webpki-certs")]
1264    {
1265        CertificateAuthority::WebpkiRoots
1266    }
1267    #[cfg(all(not(feature = "tls-webpki-certs"), feature = "tls-native-certs"))]
1268    {
1269        CertificateAuthority::OsRoots
1270    }
1271    #[cfg(not(any(feature = "tls-webpki-certs", feature = "tls-native-certs")))]
1272    {
1273        CertificateAuthority::PemFile
1274    }
1275}
1276
1277fn parse_tls_ca(val: &str) -> Result<CertificateAuthority> {
1278    Ok(match val {
1279        #[cfg(feature = "tls-webpki-certs")]
1280        "webpki_roots" => CertificateAuthority::WebpkiRoots,
1281        #[cfg(not(feature = "tls-webpki-certs"))]
1282        "webpki_roots" => {
1283            return Err(fmt!(
1284                ConfigError,
1285                "\"tls_ca=webpki_roots\" requires the \"tls-webpki-certs\" feature"
1286            ));
1287        }
1288        #[cfg(feature = "tls-native-certs")]
1289        "os_roots" => CertificateAuthority::OsRoots,
1290        #[cfg(not(feature = "tls-native-certs"))]
1291        "os_roots" => {
1292            return Err(fmt!(
1293                ConfigError,
1294                "\"tls_ca=os_roots\" requires the \"tls-native-certs\" feature"
1295            ));
1296        }
1297        #[cfg(all(feature = "tls-webpki-certs", feature = "tls-native-certs"))]
1298        "webpki_and_os_roots" => CertificateAuthority::WebpkiAndOsRoots,
1299        #[cfg(not(all(feature = "tls-webpki-certs", feature = "tls-native-certs")))]
1300        "webpki_and_os_roots" => {
1301            return Err(fmt!(
1302                ConfigError,
1303                "\"tls_ca=webpki_and_os_roots\" requires both the \"tls-webpki-certs\" and \"tls-native-certs\" features"
1304            ));
1305        }
1306        "pem_file" => CertificateAuthority::PemFile,
1307        other => {
1308            return Err(fmt!(
1309                ConfigError,
1310                "\"tls_ca\" must be one of webpki_roots|os_roots|webpki_and_os_roots|pem_file (got {:?})",
1311                other
1312            ));
1313        }
1314    })
1315}
1316
1317fn parse_value<T>(name: &str, raw: &str) -> Result<T>
1318where
1319    T: FromStr,
1320{
1321    raw.parse::<T>()
1322        .map_err(|_| fmt!(ConfigError, "Could not parse \"{}\" value: {:?}", name, raw))
1323}
1324
1325fn parse_bool(name: &str, raw: &str) -> Result<bool> {
1326    match raw {
1327        "true" | "on" | "yes" | "1" => Ok(true),
1328        "false" | "off" | "no" | "0" => Ok(false),
1329        _ => Err(fmt!(
1330            ConfigError,
1331            "\"{}\" must be a boolean (got {:?})",
1332            name,
1333            raw
1334        )),
1335    }
1336}
1337
1338/// Reject a CR (0x0D) or LF (0x0A) in `val`. Used by parse-time
1339/// handling of `client_id` and `zone` and re-applied by `validate()`
1340/// so that post-parse field mutation (the `pub` fields on
1341/// `ReaderConfig` allow it) can't smuggle CRLF into the WS upgrade
1342/// headers — header injection would otherwise be a one-liner from a
1343/// caller who built a config programmatically and then assigned a
1344/// hostile value.
1345fn reject_crlf(name: &str, val: &str) -> Result<()> {
1346    if val.contains('\n') || val.contains('\r') {
1347        return Err(fmt!(ConfigError, "\"{}\" must not contain CR or LF", name));
1348    }
1349    Ok(())
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354    use super::*;
1355    use crate::error::ErrorCode;
1356
1357    #[test]
1358    fn minimal_plain_conf() {
1359        let c = ReaderConfig::from_conf("ws::addr=localhost:9000").unwrap();
1360        assert_eq!(c.addrs.len(), 1);
1361        assert_eq!(c.addrs[0], Endpoint::new("localhost", 9000));
1362        assert!(!c.tls);
1363        assert_eq!(c.path, DEFAULT_PATH);
1364        assert_eq!(c.max_version, HIGHEST_KNOWN_VERSION);
1365        assert_eq!(c.compression, Compression::Raw);
1366        assert_eq!(c.url(), "ws://localhost:9000/read/v1");
1367    }
1368
1369    #[test]
1370    fn tls_scheme_changes_url() {
1371        let c = ReaderConfig::from_conf("wss::addr=h:8443").unwrap();
1372        assert!(c.tls);
1373        assert_eq!(c.url(), "wss://h:8443/read/v1");
1374    }
1375
1376    #[test]
1377    fn ws_scheme_is_plain() {
1378        let c = ReaderConfig::from_conf("ws::addr=localhost:9000").unwrap();
1379        assert!(!c.tls);
1380        assert_eq!(c.url(), "ws://localhost:9000/read/v1");
1381    }
1382
1383    #[test]
1384    fn wss_scheme_is_tls() {
1385        let c = ReaderConfig::from_conf("wss::addr=h:8443").unwrap();
1386        assert!(c.tls);
1387        assert_eq!(c.url(), "wss://h:8443/read/v1");
1388    }
1389
1390    #[test]
1391    fn unknown_scheme_rejected() {
1392        let err = ReaderConfig::from_conf("http::addr=h:1").unwrap_err();
1393        assert_eq!(err.code(), ErrorCode::ConfigError);
1394    }
1395
1396    #[test]
1397    fn missing_addr_rejected() {
1398        let err = ReaderConfig::from_conf("ws::path=/read/v1").unwrap_err();
1399        assert_eq!(err.code(), ErrorCode::ConfigError);
1400    }
1401
1402    #[test]
1403    fn unknown_key_rejected() {
1404        let err = ReaderConfig::from_conf("ws::addr=h:1;mystery=x").unwrap_err();
1405        assert_eq!(err.code(), ErrorCode::ConfigError);
1406    }
1407
1408    #[test]
1409    fn basic_auth_in_conf() {
1410        let c = ReaderConfig::from_conf("ws::addr=h:1;username=admin;password=quest").unwrap();
1411        assert_eq!(
1412            c.auth.header_value(),
1413            Some("Basic YWRtaW46cXVlc3Q=".to_string())
1414        );
1415    }
1416
1417    #[test]
1418    fn bearer_in_conf() {
1419        let c = ReaderConfig::from_conf("ws::addr=h:1;token=tok").unwrap();
1420        assert_eq!(c.auth.header_value(), Some("Bearer tok".to_string()));
1421    }
1422
1423    #[test]
1424    fn auth_modes_mutually_exclusive() {
1425        let err =
1426            ReaderConfig::from_conf("ws::addr=h:1;username=u;password=p;token=t").unwrap_err();
1427        assert_eq!(err.code(), ErrorCode::ConfigError);
1428    }
1429
1430    #[cfg(not(feature = "sync-reader-zstd"))]
1431    #[test]
1432    fn compression_zstd_rejected_without_feature() {
1433        let err = ReaderConfig::from_conf("ws::addr=h:1;compression=zstd").unwrap_err();
1434        assert_eq!(err.code(), ErrorCode::ConfigError);
1435        let err = ReaderConfig::from_conf("ws::addr=h:1;compression=auto").unwrap_err();
1436        assert_eq!(err.code(), ErrorCode::ConfigError);
1437    }
1438
1439    #[cfg(feature = "sync-reader-zstd")]
1440    #[test]
1441    fn compression_zstd_accepted_with_feature() {
1442        let c = ReaderConfig::from_conf("ws::addr=h:1;compression=zstd").unwrap();
1443        assert_eq!(c.compression, Compression::Zstd);
1444        let c = ReaderConfig::from_conf("ws::addr=h:1;compression=auto").unwrap();
1445        assert_eq!(c.compression, Compression::Auto);
1446    }
1447
1448    #[test]
1449    fn invalid_compression_value() {
1450        let err = ReaderConfig::from_conf("ws::addr=h:1;compression=xyz").unwrap_err();
1451        assert_eq!(err.code(), ErrorCode::ConfigError);
1452    }
1453
1454    #[test]
1455    fn compression_level_default_is_one() {
1456        let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
1457        assert_eq!(c.compression_level, DEFAULT_COMPRESSION_LEVEL);
1458        assert_eq!(c.compression_level, 1);
1459    }
1460
1461    #[cfg(feature = "sync-reader-zstd")]
1462    #[test]
1463    fn compression_level_parses_and_is_emitted() {
1464        let c =
1465            ReaderConfig::from_conf("ws::addr=h:1;compression=zstd;compression_level=9").unwrap();
1466        assert_eq!(c.compression_level, 9);
1467        let headers = c.upgrade_headers();
1468        let accept = headers
1469            .iter()
1470            .find(|(n, _)| *n == "X-QWP-Accept-Encoding")
1471            .expect("accept-encoding header present");
1472        assert_eq!(accept.1, "zstd;level=9");
1473    }
1474
1475    #[cfg(feature = "sync-reader-zstd")]
1476    #[test]
1477    fn compression_level_emitted_for_auto() {
1478        let c =
1479            ReaderConfig::from_conf("ws::addr=h:1;compression=auto;compression_level=7").unwrap();
1480        let headers = c.upgrade_headers();
1481        let accept = headers
1482            .iter()
1483            .find(|(n, _)| *n == "X-QWP-Accept-Encoding")
1484            .expect("accept-encoding header present");
1485        // First match wins per wire-egress.md §3 — zstd before raw.
1486        assert_eq!(accept.1, "zstd;level=7,raw");
1487    }
1488
1489    #[test]
1490    fn compression_level_ignored_for_raw() {
1491        // Setting `compression_level` against `compression=raw` is harmless
1492        // (the spec says `level=N` only applies to zstd). The header value
1493        // collapses to the bare `raw` token.
1494        let c = ReaderConfig::from_conf("ws::addr=h:1;compression_level=15").unwrap();
1495        let headers = c.upgrade_headers();
1496        let accept = headers
1497            .iter()
1498            .find(|(n, _)| *n == "X-QWP-Accept-Encoding")
1499            .expect("accept-encoding header present");
1500        assert_eq!(accept.1, "raw");
1501    }
1502
1503    #[test]
1504    fn compression_level_out_of_range_rejected() {
1505        for bad in ["0", "23", "100"] {
1506            let err = ReaderConfig::from_conf(format!("ws::addr=h:1;compression_level={}", bad))
1507                .unwrap_err();
1508            assert_eq!(
1509                err.code(),
1510                ErrorCode::ConfigError,
1511                "compression_level={} must be rejected",
1512                bad
1513            );
1514        }
1515    }
1516
1517    #[test]
1518    fn compression_level_accepts_full_range() {
1519        for ok in [
1520            MIN_COMPRESSION_LEVEL,
1521            DEFAULT_COMPRESSION_LEVEL,
1522            MAX_COMPRESSION_LEVEL,
1523        ] {
1524            let c = ReaderConfig::from_conf(format!("ws::addr=h:1;compression_level={}", ok))
1525                .expect("level in-range");
1526            assert_eq!(c.compression_level, ok);
1527        }
1528    }
1529
1530    #[test]
1531    fn target_parses() {
1532        let c = ReaderConfig::from_conf("ws::addr=h:1;target=primary").unwrap();
1533        assert_eq!(c.target, Target::Primary);
1534    }
1535
1536    #[test]
1537    fn multi_addr_parses() {
1538        let c = ReaderConfig::from_conf("ws::addr=h1:9000,h2:9001,h3,h4:9999;").unwrap();
1539        assert_eq!(c.addrs.len(), 4);
1540        assert_eq!(c.addrs[0], Endpoint::new("h1", 9000));
1541        assert_eq!(c.addrs[1], Endpoint::new("h2", 9001));
1542        assert_eq!(c.addrs[2], Endpoint::new("h3", 9000)); // default port
1543        assert_eq!(c.addrs[3], Endpoint::new("h4", 9999));
1544    }
1545
1546    #[test]
1547    fn empty_addr_entry_rejected() {
1548        let err = ReaderConfig::from_conf("ws::addr=h1:9000,,h2:9001;").unwrap_err();
1549        assert_eq!(err.code(), ErrorCode::ConfigError);
1550    }
1551
1552    #[test]
1553    fn target_invalid_rejected() {
1554        let err = ReaderConfig::from_conf("ws::addr=h:1;target=leader").unwrap_err();
1555        assert_eq!(err.code(), ErrorCode::ConfigError);
1556    }
1557
1558    #[test]
1559    fn upgrade_headers_default() {
1560        let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
1561        let h = c.upgrade_headers();
1562        // Always emit max_version + accept-encoding; nothing else by default.
1563        assert_eq!(h.len(), 2);
1564        assert_eq!(h[0], ("X-QWP-Max-Version", "1".to_string()));
1565        assert_eq!(h[1], ("X-QWP-Accept-Encoding", "raw".to_string()));
1566    }
1567
1568    #[test]
1569    fn upgrade_headers_full_set() {
1570        let c = ReaderConfig::from_conf(
1571            "ws::addr=h:1;client_id=app1;max_batch_rows=1000;username=u;password=p",
1572        )
1573        .unwrap();
1574        let h = c.upgrade_headers();
1575        let names: Vec<_> = h.iter().map(|(n, _)| *n).collect();
1576        assert!(names.contains(&"X-QWP-Max-Version"));
1577        assert!(names.contains(&"X-QWP-Client-Id"));
1578        assert!(names.contains(&"X-QWP-Accept-Encoding"));
1579        assert!(names.contains(&"X-QWP-Max-Batch-Rows"));
1580        assert!(names.contains(&"Authorization"));
1581        assert!(!names.contains(&"X-QWP-Request-Durable-Ack"));
1582
1583        // max_batch_rows omitted when 0.
1584        let c = ReaderConfig::from_conf("ws::addr=h:1;max_batch_rows=0").unwrap();
1585        let h = c.upgrade_headers();
1586        assert!(h.iter().all(|(n, _)| *n != "X-QWP-Max-Batch-Rows"));
1587    }
1588
1589    #[test]
1590    fn path_must_start_with_slash() {
1591        let err = ReaderConfig::from_conf("ws::addr=h:1;path=read/v1").unwrap_err();
1592        assert_eq!(err.code(), ErrorCode::ConfigError);
1593    }
1594
1595    #[test]
1596    fn default_port_when_omitted() {
1597        let c = ReaderConfig::from_conf("ws::addr=localhost").unwrap();
1598        assert_eq!(c.addrs[0].port, 9000);
1599    }
1600
1601    #[test]
1602    fn invalid_port_rejected() {
1603        let err = ReaderConfig::from_conf("ws::addr=h:notaport").unwrap_err();
1604        assert_eq!(err.code(), ErrorCode::ConfigError);
1605    }
1606
1607    #[test]
1608    fn port_zero_rejected() {
1609        // Port 0 means "let the OS pick" for *listeners*; for an
1610        // outbound connect target it's nonsense. Parse-time rejection
1611        // gives a precise diagnostic instead of a downstream
1612        // EADDRNOTAVAIL / ECONNREFUSED with a misleading message.
1613        let err = ReaderConfig::from_conf("ws::addr=h:0").unwrap_err();
1614        assert_eq!(err.code(), ErrorCode::ConfigError);
1615        assert!(
1616            err.msg().contains("Port 0"),
1617            "diagnostic must name the offending value; got: {}",
1618            err.msg()
1619        );
1620        // Reject when port 0 is one of several entries, too —
1621        // partial-zero lists shouldn't slip past.
1622        let err = ReaderConfig::from_conf("ws::addr=a:9000,b:0").unwrap_err();
1623        assert_eq!(err.code(), ErrorCode::ConfigError);
1624        // And in the IPv6-bracketed path (which funnels through the
1625        // same `port_str.parse()` site).
1626        let err = ReaderConfig::from_conf("ws::addr=[::1]:0").unwrap_err();
1627        assert_eq!(err.code(), ErrorCode::ConfigError);
1628    }
1629
1630    #[test]
1631    fn tls_keys_with_plain_scheme_rejected() {
1632        let err = ReaderConfig::from_conf("ws::addr=h:1;tls_roots=/tmp/x").unwrap_err();
1633        assert_eq!(err.code(), ErrorCode::ConfigError);
1634        let err = ReaderConfig::from_conf("ws::addr=h:1;tls_ca=pem_file").unwrap_err();
1635        assert_eq!(err.code(), ErrorCode::ConfigError);
1636    }
1637
1638    // `tls_verify=unsafe_off` is gated by the `insecure-skip-verify`
1639    // crate feature. The feature gate has to be enforced by `validate()`
1640    // (not just at parse time) because the field is `pub`: a caller can
1641    // build a clean config via `from_conf` and then assign
1642    // `cfg.tls_verify = TlsVerify::UnsafeOff` directly. The TLS builder
1643    // silently downgrades to the default verifier when the feature is
1644    // off, so without the validate-time check the caller's explicit
1645    // "off" intent would be lost without diagnostic.
1646    #[cfg(not(feature = "insecure-skip-verify"))]
1647    #[test]
1648    fn validate_rejects_unsafe_off_when_feature_disabled() {
1649        // Parse-time rejection: the existing behaviour, still in place.
1650        let err = ReaderConfig::from_conf("wss::addr=h:1;tls_verify=unsafe_off").unwrap_err();
1651        assert_eq!(err.code(), ErrorCode::ConfigError);
1652        assert!(
1653            err.msg().contains("insecure-skip-verify"),
1654            "msg: {}",
1655            err.msg()
1656        );
1657
1658        // Post-parse mutation: builds a clean config first, then flips
1659        // the field. Without the re-check in `validate()` this path
1660        // would pass silently.
1661        let mut cfg = ReaderConfig::from_conf("wss::addr=h:1").unwrap();
1662        assert_eq!(cfg.tls_verify, TlsVerify::On);
1663        cfg.tls_verify = TlsVerify::UnsafeOff;
1664        let err = cfg.validate().unwrap_err();
1665        assert_eq!(err.code(), ErrorCode::ConfigError);
1666        assert!(
1667            err.msg().contains("insecure-skip-verify"),
1668            "msg: {}",
1669            err.msg()
1670        );
1671    }
1672
1673    #[cfg(feature = "insecure-skip-verify")]
1674    #[test]
1675    fn validate_accepts_unsafe_off_when_feature_enabled() {
1676        // Mirror of `validate_rejects_unsafe_off_when_feature_disabled`
1677        // — pins that the feature gate only fires in the off direction.
1678        let cfg = ReaderConfig::from_conf("wss::addr=h:1;tls_verify=unsafe_off").unwrap();
1679        assert_eq!(cfg.tls_verify, TlsVerify::UnsafeOff);
1680        cfg.validate()
1681            .expect("unsafe_off must pass validate when feature is on");
1682    }
1683
1684    #[test]
1685    fn tls_roots_password_without_tls_roots_rejected() {
1686        // The password unlocks the keystore at `tls_roots`. Setting
1687        // the password without naming the file is meaningless and
1688        // would silently fall back to the default trust source —
1689        // not what the caller asked for.
1690        let err = ReaderConfig::from_conf("wss::addr=h:1;tls_roots_password=secret").unwrap_err();
1691        assert_eq!(err.code(), ErrorCode::ConfigError);
1692        assert!(
1693            err.msg().contains("tls_roots_password") && err.msg().contains("tls_roots"),
1694            "msg: {}",
1695            err.msg()
1696        );
1697    }
1698
1699    #[test]
1700    fn tls_roots_password_without_tls_scheme_rejected() {
1701        // `ws::` is plaintext — no TLS to configure. Reject all TLS
1702        // knobs (`tls_roots`, `tls_roots_password`, etc.) the same
1703        // way, with the same scheme-mismatch diagnostic.
1704        let err =
1705            ReaderConfig::from_conf("ws::addr=h:1;tls_roots=/tmp/r;tls_roots_password=secret")
1706                .unwrap_err();
1707        assert_eq!(err.code(), ErrorCode::ConfigError);
1708    }
1709
1710    #[test]
1711    fn tls_roots_password_with_tls_roots_accepted() {
1712        let c = ReaderConfig::from_conf(
1713            "wss::addr=h:1;tls_roots=/path/to/store.jks;tls_roots_password=secret",
1714        )
1715        .unwrap();
1716        assert_eq!(c.tls_ca, CertificateAuthority::PemFile);
1717        assert_eq!(c.tls_roots_password.as_deref(), Some("secret"));
1718    }
1719
1720    #[test]
1721    fn tls_roots_implies_pem_file_ca() {
1722        let c = ReaderConfig::from_conf("wss::addr=h:1;tls_roots=/path/to/roots.pem").unwrap();
1723        assert_eq!(c.tls_ca, CertificateAuthority::PemFile);
1724        assert_eq!(
1725            c.tls_roots.as_deref(),
1726            Some(std::path::Path::new("/path/to/roots.pem"))
1727        );
1728    }
1729
1730    #[test]
1731    fn tls_roots_with_conflicting_ca_rejected() {
1732        #[cfg(feature = "tls-webpki-certs")]
1733        {
1734            let err = ReaderConfig::from_conf("wss::addr=h:1;tls_ca=webpki_roots;tls_roots=/tmp/x")
1735                .unwrap_err();
1736            assert_eq!(err.code(), ErrorCode::ConfigError);
1737        }
1738    }
1739
1740    #[test]
1741    fn tls_ca_pem_file_explicit() {
1742        let c =
1743            ReaderConfig::from_conf("wss::addr=h:1;tls_ca=pem_file;tls_roots=/tmp/r.pem").unwrap();
1744        assert_eq!(c.tls_ca, CertificateAuthority::PemFile);
1745    }
1746
1747    #[test]
1748    fn tls_ca_invalid_value_rejected() {
1749        let err = ReaderConfig::from_conf("wss::addr=h:1;tls_ca=mystery").unwrap_err();
1750        assert_eq!(err.code(), ErrorCode::ConfigError);
1751    }
1752
1753    #[cfg(feature = "tls-webpki-certs")]
1754    #[test]
1755    fn tls_ca_webpki_roots_default() {
1756        let c = ReaderConfig::from_conf("wss::addr=h:1").unwrap();
1757        assert_eq!(c.tls_ca, CertificateAuthority::WebpkiRoots);
1758        assert_eq!(c.tls_roots, None);
1759    }
1760
1761    #[test]
1762    fn durable_ack_key_rejected() {
1763        // `durable_ack` is an ingress-spec carryover with no egress
1764        // semantic — the key was removed from the egress connect string
1765        // (spec §3 lists exactly four C->S headers; the corresponding
1766        // X-QWP-Request-Durable-Ack header was also removed). A connect
1767        // string still carrying the key now fails parsing rather than
1768        // being silently honoured.
1769        let err = ReaderConfig::from_conf("ws::addr=h:1;durable_ack=true").unwrap_err();
1770        assert!(
1771            err.msg().to_lowercase().contains("durable_ack")
1772                || err.msg().to_lowercase().contains("unknown")
1773        );
1774    }
1775
1776    #[test]
1777    fn failover_defaults() {
1778        let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
1779        assert!(c.failover);
1780        assert_eq!(c.failover_max_attempts, DEFAULT_FAILOVER_MAX_ATTEMPTS);
1781        assert_eq!(
1782            c.failover_backoff_initial_ms,
1783            DEFAULT_FAILOVER_BACKOFF_INITIAL_MS
1784        );
1785        assert_eq!(c.failover_backoff_max_ms, DEFAULT_FAILOVER_BACKOFF_MAX_MS);
1786    }
1787
1788    #[test]
1789    fn failover_keys_parsed() {
1790        let c = ReaderConfig::from_conf(
1791            "ws::addr=h:1;failover=off;failover_max_attempts=3;failover_backoff_initial_ms=100;failover_backoff_max_ms=2000",
1792        )
1793        .unwrap();
1794        assert!(!c.failover);
1795        assert_eq!(c.failover_max_attempts, 3);
1796        assert_eq!(c.failover_backoff_initial_ms, 100);
1797        assert_eq!(c.failover_backoff_max_ms, 2000);
1798    }
1799
1800    #[test]
1801    fn failover_backoff_initial_zero_disables_sleep() {
1802        let c = ReaderConfig::from_conf("ws::addr=h:1;failover_backoff_initial_ms=0").unwrap();
1803        assert_eq!(c.failover_backoff_initial_ms, 0);
1804        assert_eq!(c.failover_backoff_max_ms, DEFAULT_FAILOVER_BACKOFF_MAX_MS);
1805    }
1806
1807    #[test]
1808    fn failover_backoff_max_below_initial_rejected() {
1809        let err = ReaderConfig::from_conf(
1810            "ws::addr=h:1;failover_backoff_initial_ms=500;failover_backoff_max_ms=100",
1811        )
1812        .unwrap_err();
1813        assert_eq!(err.code(), ErrorCode::ConfigError);
1814    }
1815
1816    #[test]
1817    fn failover_invalid_attempts_rejected() {
1818        let err = ReaderConfig::from_conf("ws::addr=h:1;failover_max_attempts=abc").unwrap_err();
1819        assert_eq!(err.code(), ErrorCode::ConfigError);
1820    }
1821
1822    #[test]
1823    fn failover_max_attempts_above_cap_rejected() {
1824        let conf = format!(
1825            "ws::addr=h:1;failover_max_attempts={}",
1826            MAX_FAILOVER_MAX_ATTEMPTS + 1
1827        );
1828        let err = ReaderConfig::from_conf(&conf).unwrap_err();
1829        assert_eq!(err.code(), ErrorCode::ConfigError);
1830        assert!(err.msg().contains("exceeds the hard cap"));
1831    }
1832
1833    #[test]
1834    fn failover_max_attempts_at_cap_accepted() {
1835        let conf = format!(
1836            "ws::addr=h:1;failover_max_attempts={}",
1837            MAX_FAILOVER_MAX_ATTEMPTS
1838        );
1839        let c = ReaderConfig::from_conf(&conf).unwrap();
1840        assert_eq!(c.failover_max_attempts, MAX_FAILOVER_MAX_ATTEMPTS);
1841    }
1842
1843    #[test]
1844    fn failover_backoff_max_above_cap_rejected() {
1845        // N6 regression guard: a misconfigured `failover_backoff_max_ms`
1846        // beyond `MAX_FAILOVER_BACKOFF_MAX_MS` (1 hour) must be
1847        // rejected at parse time so a failover storm can't burn
1848        // multi-hour `thread::sleep` calls inside the cursor.
1849        let conf = format!(
1850            "ws::addr=h:1;failover_backoff_initial_ms=1;failover_backoff_max_ms={}",
1851            MAX_FAILOVER_BACKOFF_MAX_MS + 1
1852        );
1853        let err = ReaderConfig::from_conf(&conf).unwrap_err();
1854        assert_eq!(err.code(), ErrorCode::ConfigError);
1855        assert!(
1856            err.msg().contains("exceeds the hard cap"),
1857            "msg: {}",
1858            err.msg()
1859        );
1860    }
1861
1862    #[test]
1863    fn failover_backoff_max_at_cap_accepted() {
1864        let conf = format!(
1865            "ws::addr=h:1;failover_backoff_initial_ms=1;failover_backoff_max_ms={}",
1866            MAX_FAILOVER_BACKOFF_MAX_MS
1867        );
1868        let c = ReaderConfig::from_conf(&conf).unwrap();
1869        assert_eq!(c.failover_backoff_max_ms, MAX_FAILOVER_BACKOFF_MAX_MS);
1870    }
1871
1872    // --- zone / auth_timeout_ms / failover_max_duration_ms (failover.md §1.1, §11.9.1) ---
1873
1874    #[test]
1875    fn zone_unset_is_none_by_default() {
1876        let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
1877        assert_eq!(c.zone, None);
1878    }
1879
1880    #[test]
1881    fn zone_parses() {
1882        let c = ReaderConfig::from_conf("ws::addr=h:1;zone=eu-west-1a").unwrap();
1883        assert_eq!(c.zone.as_deref(), Some("eu-west-1a"));
1884    }
1885
1886    #[test]
1887    fn zone_empty_or_whitespace_normalises_to_none() {
1888        let c = ReaderConfig::from_conf("ws::addr=h:1;zone=").unwrap();
1889        assert_eq!(c.zone, None, "empty value collapses to unset");
1890        let c = ReaderConfig::from_conf("ws::addr=h:1;zone=   ").unwrap();
1891        assert_eq!(c.zone, None, "whitespace-only collapses to unset");
1892    }
1893
1894    #[test]
1895    fn zone_trims_value() {
1896        let c = ReaderConfig::from_conf("ws::addr=h:1;zone=  eu-west-1a  ").unwrap();
1897        assert_eq!(c.zone.as_deref(), Some("eu-west-1a"));
1898    }
1899
1900    #[test]
1901    fn zone_rejects_cr_lf() {
1902        // CRLF in a zone value would smuggle into log lines and any
1903        // header that re-serialises it. Reject up front.
1904        let err = ReaderConfig::from_conf("ws::addr=h:1;zone=eu\nwest").unwrap_err();
1905        assert_eq!(err.code(), ErrorCode::ConfigError);
1906        let err = ReaderConfig::from_conf("ws::addr=h:1;zone=eu\rwest").unwrap_err();
1907        assert_eq!(err.code(), ErrorCode::ConfigError);
1908    }
1909
1910    #[test]
1911    fn auth_timeout_defaults_to_15s() {
1912        let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
1913        assert_eq!(c.auth_timeout_ms, DEFAULT_AUTH_TIMEOUT_MS);
1914        assert_eq!(DEFAULT_AUTH_TIMEOUT_MS, 15_000);
1915    }
1916
1917    #[test]
1918    fn auth_timeout_parses() {
1919        let c = ReaderConfig::from_conf("ws::addr=h:1;auth_timeout_ms=3000").unwrap();
1920        assert_eq!(c.auth_timeout_ms, 3_000);
1921    }
1922
1923    #[test]
1924    fn auth_timeout_zero_rejected() {
1925        // No "unbounded" sentinel — 0 is misconfiguration. Pinning a
1926        // thread waiting on a single peer indefinitely is what we're
1927        // trying to *avoid* with this knob.
1928        let err = ReaderConfig::from_conf("ws::addr=h:1;auth_timeout_ms=0").unwrap_err();
1929        assert_eq!(err.code(), ErrorCode::ConfigError);
1930        assert!(err.msg().contains("auth_timeout_ms"), "msg: {}", err.msg());
1931    }
1932
1933    #[test]
1934    fn auth_timeout_above_cap_rejected() {
1935        let conf = format!("ws::addr=h:1;auth_timeout_ms={}", MAX_AUTH_TIMEOUT_MS + 1);
1936        let err = ReaderConfig::from_conf(&conf).unwrap_err();
1937        assert_eq!(err.code(), ErrorCode::ConfigError);
1938        assert!(err.msg().contains("exceeds the hard cap"));
1939    }
1940
1941    #[test]
1942    fn auth_timeout_at_cap_accepted() {
1943        let conf = format!("ws::addr=h:1;auth_timeout_ms={}", MAX_AUTH_TIMEOUT_MS);
1944        let c = ReaderConfig::from_conf(&conf).unwrap();
1945        assert_eq!(c.auth_timeout_ms, MAX_AUTH_TIMEOUT_MS);
1946    }
1947
1948    #[test]
1949    fn connect_timeout_defaults_to_os_default() {
1950        let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
1951        assert_eq!(
1952            c.connect_timeout_ms, 0,
1953            "default is the OS-default dial (0)"
1954        );
1955    }
1956
1957    #[test]
1958    fn connect_timeout_parses_from_connect_string() {
1959        let c = ReaderConfig::from_conf("ws::addr=h:1;connect_timeout=250").unwrap();
1960        assert_eq!(c.connect_timeout_ms, 250);
1961    }
1962
1963    #[test]
1964    fn connect_timeout_zero_is_os_default() {
1965        // `0` is the documented sentinel for "no client connect timeout";
1966        // must not be rejected.
1967        let c = ReaderConfig::from_conf("ws::addr=h:1;connect_timeout=0").unwrap();
1968        assert_eq!(c.connect_timeout_ms, 0);
1969    }
1970
1971    #[test]
1972    fn connect_timeout_at_cap_accepted() {
1973        let conf = format!("ws::addr=h:1;connect_timeout={}", MAX_CONNECT_TIMEOUT_MS);
1974        let c = ReaderConfig::from_conf(&conf).unwrap();
1975        assert_eq!(c.connect_timeout_ms, MAX_CONNECT_TIMEOUT_MS);
1976    }
1977
1978    #[test]
1979    fn connect_timeout_above_cap_rejected() {
1980        let conf = format!(
1981            "ws::addr=h:1;connect_timeout={}",
1982            MAX_CONNECT_TIMEOUT_MS + 1
1983        );
1984        let err = ReaderConfig::from_conf(&conf).unwrap_err();
1985        assert_eq!(err.code(), ErrorCode::ConfigError);
1986        assert!(err.msg().contains("connect_timeout"), "msg: {}", err.msg());
1987    }
1988
1989    #[test]
1990    fn failover_max_duration_defaults_to_30s() {
1991        let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
1992        assert_eq!(c.failover_max_duration_ms, DEFAULT_FAILOVER_MAX_DURATION_MS);
1993        assert_eq!(DEFAULT_FAILOVER_MAX_DURATION_MS, 30_000);
1994    }
1995
1996    #[test]
1997    fn failover_max_duration_parses() {
1998        let c = ReaderConfig::from_conf("ws::addr=h:1;failover_max_duration_ms=60000").unwrap();
1999        assert_eq!(c.failover_max_duration_ms, 60_000);
2000    }
2001
2002    #[test]
2003    fn failover_max_duration_zero_is_unbounded() {
2004        // `0` is the documented sentinel for "no wall-clock cap" per
2005        // wire-egress.md §11.9.1. Must not be rejected.
2006        let c = ReaderConfig::from_conf("ws::addr=h:1;failover_max_duration_ms=0").unwrap();
2007        assert_eq!(c.failover_max_duration_ms, 0);
2008    }
2009
2010    #[test]
2011    fn failover_max_duration_above_cap_rejected() {
2012        let conf = format!(
2013            "ws::addr=h:1;failover_max_duration_ms={}",
2014            MAX_FAILOVER_MAX_DURATION_MS + 1
2015        );
2016        let err = ReaderConfig::from_conf(&conf).unwrap_err();
2017        assert_eq!(err.code(), ErrorCode::ConfigError);
2018        assert!(err.msg().contains("exceeds the hard cap"));
2019    }
2020
2021    // --- server_info_timeout_ms (programmatic-only; not parsed from connect-string) ---
2022
2023    #[test]
2024    fn server_info_timeout_defaults_to_5s() {
2025        let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
2026        assert_eq!(c.server_info_timeout_ms, DEFAULT_SERVER_INFO_TIMEOUT_MS);
2027        assert_eq!(DEFAULT_SERVER_INFO_TIMEOUT_MS, 5_000);
2028    }
2029
2030    #[test]
2031    fn server_info_timeout_is_not_parsed_from_connect_string() {
2032        // Java parity: `withServerInfoTimeout` is programmatic-only. The
2033        // connect-string key MUST be rejected (covered by the generic
2034        // "unknown config key" branch) so a user typo doesn't get
2035        // silently ignored.
2036        let err = ReaderConfig::from_conf("ws::addr=h:1;server_info_timeout_ms=1000").unwrap_err();
2037        assert_eq!(err.code(), ErrorCode::ConfigError);
2038        assert!(
2039            err.msg().contains("Unknown config key"),
2040            "msg: {}",
2041            err.msg()
2042        );
2043    }
2044
2045    // --- reserved per-category server-error policy keys ---
2046    //
2047    // Java parity (design/qwp-cursor-error-api.md): `on_server_error`,
2048    // `on_schema_error`, `on_parse_error`, `on_internal_error`,
2049    // `on_security_error`, `on_write_error` are reserved so the same
2050    // connect string can be shared between language clients regardless
2051    // of which side has wired the policy resolver. The reader parser
2052    // accepts the keys without inspecting the values.
2053
2054    const RESERVED_ON_ERROR_KEYS: &[&str] = &[
2055        "on_server_error",
2056        "on_schema_error",
2057        "on_parse_error",
2058        "on_internal_error",
2059        "on_security_error",
2060        "on_write_error",
2061    ];
2062
2063    #[test]
2064    fn reserved_on_error_policy_keys_all_together_are_accepted_silently() {
2065        let conf = "ws::addr=h:1\
2066            ;on_server_error=halt\
2067            ;on_schema_error=drop\
2068            ;on_parse_error=halt\
2069            ;on_internal_error=halt\
2070            ;on_security_error=halt\
2071            ;on_write_error=drop";
2072        let c = ReaderConfig::from_conf(conf).unwrap();
2073        assert_eq!(c.addrs.len(), 1);
2074        assert_eq!(c.addrs[0].host, "h");
2075        assert_eq!(c.addrs[0].port, 1);
2076    }
2077
2078    #[test]
2079    fn reserved_on_error_policy_keys_each_accepted_individually() {
2080        for key in RESERVED_ON_ERROR_KEYS {
2081            let conf = format!("ws::addr=h:1;{key}=halt");
2082            ReaderConfig::from_conf(&conf)
2083                .unwrap_or_else(|e| panic!("expected {key:?} to parse, got {}", e.msg()));
2084        }
2085    }
2086
2087    #[test]
2088    fn reserved_on_error_policy_keys_accept_any_value_without_validation() {
2089        // The spec value alphabet is `halt|drop` (plus `auto` for the
2090        // global `on_server_error`), but the reader does not validate
2091        // because validation would defeat the cross-language
2092        // forward-compat purpose: a newer client may use values this
2093        // reader has never heard of (e.g. `dlq`) and we must still
2094        // accept the connect string.
2095        for key in RESERVED_ON_ERROR_KEYS {
2096            for val in ["halt", "drop", "auto", "anything", ""] {
2097                let conf = format!("ws::addr=h:1;{key}={val}");
2098                ReaderConfig::from_conf(&conf)
2099                    .unwrap_or_else(|e| panic!("expected {key}={val:?} to parse, got {}", e.msg()));
2100            }
2101        }
2102    }
2103
2104    #[test]
2105    fn reserved_on_error_policy_keys_do_not_swallow_other_settings() {
2106        // Make sure adding the reserved keys does not interfere with
2107        // the surrounding parser state (e.g. by accidentally consuming
2108        // the next key/value pair).
2109        let conf = "ws::addr=h:1;on_schema_error=drop;target=primary;zone=eu-1";
2110        let c = ReaderConfig::from_conf(conf).unwrap();
2111        assert_eq!(c.target, Target::Primary);
2112        assert_eq!(c.zone.as_deref(), Some("eu-1"));
2113    }
2114
2115    #[test]
2116    fn reserved_on_error_policy_keys_typo_still_rejected() {
2117        // Guard against accidentally widening the match to a prefix:
2118        // a near-miss must still hit the "unknown config key" branch.
2119        for typo in [
2120            "on_server_err",
2121            "on_schema_errors",
2122            "on_parse",
2123            "On_Write_Error",
2124        ] {
2125            let conf = format!("ws::addr=h:1;{typo}=halt");
2126            let err = ReaderConfig::from_conf(&conf)
2127                .err()
2128                .unwrap_or_else(|| panic!("expected {typo:?} to be rejected"));
2129            assert_eq!(err.code(), ErrorCode::ConfigError);
2130            assert!(
2131                err.msg().contains("Unknown config key"),
2132                "typo {typo:?}: msg: {}",
2133                err.msg()
2134            );
2135        }
2136    }
2137
2138    // --- reserved `buffer_pool_size` key ---
2139    //
2140    // Java parity (QwpQueryClient.java:505-512): sizes the I/O thread's
2141    // decoded-batch pool. Rust egress is sync/pull-based and has no
2142    // such pool, so the key is accepted without inspecting the value
2143    // (see comment in `from_conf`).
2144
2145    #[test]
2146    fn reserved_buffer_pool_size_accepts_any_value_without_validation() {
2147        // Spec range is `>= 1`, but a stricter reader would defeat the
2148        // forward-compat purpose: refuse nothing the Java side would
2149        // accept, and refuse nothing it would reject either (the
2150        // ignored knob is the user's contract).
2151        for val in ["1", "4", "1024", "0", "-1", "not-a-number", ""] {
2152            let conf = format!("ws::addr=h:1;buffer_pool_size={val}");
2153            ReaderConfig::from_conf(&conf).unwrap_or_else(|e| {
2154                panic!(
2155                    "expected buffer_pool_size={val:?} to parse, got {}",
2156                    e.msg()
2157                )
2158            });
2159        }
2160    }
2161
2162    #[test]
2163    fn reserved_buffer_pool_size_does_not_swallow_other_settings() {
2164        let conf = "ws::addr=h:1;buffer_pool_size=8;target=replica;zone=us-2";
2165        let c = ReaderConfig::from_conf(conf).unwrap();
2166        assert_eq!(c.target, Target::Replica);
2167        assert_eq!(c.zone.as_deref(), Some("us-2"));
2168    }
2169
2170    // --- cross-role connect-string portability ---
2171
2172    #[test]
2173    fn egress_silently_accepts_every_ingress_only_key() {
2174        // A connect string tuned for the ingress sender (or written
2175        // for both roles in a multi-role app) must parse on the egress
2176        // reader. We do not inspect values — the egress role doesn't
2177        // care what the sender will eventually do with them.
2178        for key in INGRESS_ONLY_CONFIG_KEYS {
2179            for val in ["1", "off", "anything", ""] {
2180                let conf = format!("ws::addr=h:1;{key}={val}");
2181                ReaderConfig::from_conf(&conf).unwrap_or_else(|e| {
2182                    panic!(
2183                        "expected egress to silently accept ingress-only \
2184                         key {key}={val:?}, got {}",
2185                        e.msg()
2186                    )
2187                });
2188            }
2189        }
2190    }
2191
2192    #[test]
2193    fn egress_rejects_removed_in_flight_keys() {
2194        // `in_flight_window` and `max_in_flight` are not configuration keys
2195        // anywhere in the client, so the cross-role tolerance above does not
2196        // extend to them: the egress reader rejects them as unknown, exactly
2197        // like the ingress sender. Keeping them out of
2198        // `INGRESS_ONLY_CONFIG_KEYS` is what this test pins down.
2199        for key in ["in_flight_window", "max_in_flight"] {
2200            let conf = format!("ws::addr=h:1;{key}=8");
2201            let err = ReaderConfig::from_conf(&conf).unwrap_err();
2202            assert_eq!(err.code(), ErrorCode::ConfigError, "key: {key}");
2203            assert!(
2204                err.msg().contains(&format!("Unknown config key \"{key}\"")),
2205                "key: {key}, msg: {}",
2206                err.msg()
2207            );
2208        }
2209    }
2210
2211    #[test]
2212    fn egress_accepts_full_ingress_connect_string_unchanged() {
2213        // End-to-end portability smoke test: a representative
2214        // ingress-flavoured connect string with multiple ingress-only
2215        // keys interleaved with shared ones parses cleanly on the
2216        // egress reader without losing the shared knobs along the way.
2217        let conf = "ws::addr=h:9000\
2218            ;username=u;password=p\
2219            ;init_buf_size=65536;max_buf_size=1048576;max_name_len=127\
2220            ;auto_flush=off;auto_flush_rows=1000\
2221            ;protocol_version=2\
2222            ;tls_verify=on\
2223            ;target=primary;zone=eu-west-1a";
2224        let c = ReaderConfig::from_conf(conf).unwrap();
2225        assert_eq!(c.addrs.len(), 1);
2226        assert_eq!(c.addrs[0], Endpoint::new("h", 9000));
2227        assert_eq!(c.target, Target::Primary);
2228        assert_eq!(c.zone.as_deref(), Some("eu-west-1a"));
2229        // Shared auth knobs survive the ingress-only key mixture.
2230        assert!(matches!(c.auth, AuthMode::Basic { .. }));
2231    }
2232
2233    #[test]
2234    fn reserved_buffer_pool_size_typo_still_rejected() {
2235        for typo in [
2236            "buffer_pool",
2237            "buffer_pool_sizes",
2238            "Buffer_Pool_Size",
2239            "buffer_size",
2240        ] {
2241            let conf = format!("ws::addr=h:1;{typo}=4");
2242            let err = ReaderConfig::from_conf(&conf)
2243                .err()
2244                .unwrap_or_else(|| panic!("expected {typo:?} to be rejected"));
2245            assert_eq!(err.code(), ErrorCode::ConfigError);
2246            assert!(
2247                err.msg().contains("Unknown config key"),
2248                "typo {typo:?}: msg: {}",
2249                err.msg()
2250            );
2251        }
2252    }
2253
2254    #[test]
2255    fn server_info_timeout_zero_rejected_by_validate() {
2256        // Programmatic mutation past the default — `validate()` is the
2257        // safety net before `Reader::from_config` opens any socket.
2258        let mut c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
2259        c.server_info_timeout_ms = 0;
2260        let err = c.validate().unwrap_err();
2261        assert_eq!(err.code(), ErrorCode::ConfigError);
2262        assert!(err.msg().contains("server_info_timeout_ms"));
2263    }
2264
2265    #[test]
2266    fn server_info_timeout_above_cap_rejected_by_validate() {
2267        let mut c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
2268        c.server_info_timeout_ms = MAX_SERVER_INFO_TIMEOUT_MS + 1;
2269        let err = c.validate().unwrap_err();
2270        assert_eq!(err.code(), ErrorCode::ConfigError);
2271        assert!(err.msg().contains("exceeds the hard cap"));
2272    }
2273
2274    #[test]
2275    fn server_info_timeout_at_cap_accepted() {
2276        let mut c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
2277        c.server_info_timeout_ms = MAX_SERVER_INFO_TIMEOUT_MS;
2278        c.validate().unwrap();
2279    }
2280
2281    #[test]
2282    fn addrs_above_cap_rejected() {
2283        // N5 regression guard: enforce `MAX_ADDRS` so the
2284        // address-rotation arithmetic in
2285        // `Reader::reconnect_with_failover` is provably free of usize
2286        // overflow on 32-bit targets.
2287        let mut addr = String::from("ws::addr=");
2288        for i in 0..(MAX_ADDRS + 1) {
2289            if i > 0 {
2290                addr.push(',');
2291            }
2292            addr.push_str(&format!("h{}:9000", i));
2293        }
2294        let err = ReaderConfig::from_conf(&addr).unwrap_err();
2295        assert_eq!(err.code(), ErrorCode::ConfigError);
2296        assert!(
2297            err.msg().contains("exceeds the hard cap"),
2298            "msg: {}",
2299            err.msg()
2300        );
2301    }
2302
2303    #[test]
2304    fn failover_max_attempts_zero_rejected() {
2305        // Matches Java QwpQueryClient.java:401 — `failover_max_attempts must be >= 1`.
2306        // Users who want failover entirely off should set `failover=off`.
2307        let err = ReaderConfig::from_conf("ws::addr=h:1;failover_max_attempts=0").unwrap_err();
2308        assert_eq!(err.code(), ErrorCode::ConfigError);
2309        assert!(
2310            err.msg().contains("failover_max_attempts"),
2311            "msg: {}",
2312            err.msg()
2313        );
2314    }
2315
2316    #[test]
2317    fn failover_max_attempts_counts_initial_execute_attempt() {
2318        let c = ReaderConfig::from_conf("ws::addr=h:1;failover_max_attempts=1").unwrap();
2319        assert_eq!(c.failover_reconnect_rounds(), 0);
2320
2321        let c = ReaderConfig::from_conf("ws::addr=h:1;failover_max_attempts=8").unwrap();
2322        assert_eq!(c.failover_reconnect_rounds(), 7);
2323    }
2324
2325    #[test]
2326    fn endpoint_display_common_cases() {
2327        // Hostnames and IPv4 literals format unbracketed — `host:port`
2328        // is the path users will actually see in connect strings,
2329        // logs, and `FailoverResetEvent` output. This is the contract the
2330        // failover doctest and example rely on.
2331        assert_eq!(
2332            Endpoint::new("localhost", 9000).to_string(),
2333            "localhost:9000"
2334        );
2335        assert_eq!(Endpoint::new("db-a", 9000).to_string(), "db-a:9000");
2336        assert_eq!(
2337            Endpoint::new("127.0.0.1", 9000).to_string(),
2338            "127.0.0.1:9000"
2339        );
2340        // Round-trip into a connect string parser: an Endpoint
2341        // formatted via Display must parse back into an
2342        // equal-by-value Endpoint, which keeps log lines and
2343        // diagnostic output safe to feed back into a new connect
2344        // string without quoting/escaping bookkeeping.
2345        let ep = Endpoint::new("example.com", 1234);
2346        let conf = format!("ws::addr={}", ep);
2347        let parsed = ReaderConfig::from_conf(&conf).expect("parse round-trip");
2348        assert_eq!(parsed.addrs(), &[ep]);
2349    }
2350
2351    #[test]
2352    fn endpoint_display_ipv6_brackets() {
2353        // IPv6 literals contain `:` and would otherwise produce an
2354        // ambiguous `host:port` collision. Bracketing follows
2355        // RFC 3986 §3.2.2 (`IP-literal`).
2356        assert_eq!(Endpoint::new("::1", 9000).to_string(), "[::1]:9000");
2357        assert_eq!(
2358            Endpoint::new("2001:db8::1", 443).to_string(),
2359            "[2001:db8::1]:443"
2360        );
2361    }
2362
2363    #[test]
2364    fn ipv6_addr_parses_with_explicit_port() {
2365        let c = ReaderConfig::from_conf("ws::addr=[::1]:9000").unwrap();
2366        assert_eq!(c.addrs.len(), 1);
2367        // Stored host is bare; brackets re-applied only by Display.
2368        assert_eq!(c.addrs[0], Endpoint::new("::1", 9000));
2369        assert_eq!(c.url_for(0), "ws://[::1]:9000/read/v1");
2370    }
2371
2372    #[test]
2373    fn ipv6_addr_default_port() {
2374        let c = ReaderConfig::from_conf("ws::addr=[2001:db8::1]").unwrap();
2375        assert_eq!(c.addrs[0], Endpoint::new("2001:db8::1", 9000));
2376        assert_eq!(c.url_for(0), "ws://[2001:db8::1]:9000/read/v1");
2377    }
2378
2379    #[test]
2380    fn ipv6_addr_in_multi_addr_list() {
2381        let c = ReaderConfig::from_conf("ws::addr=[::1]:9000,h2:9001,[2001:db8::5]").unwrap();
2382        assert_eq!(c.addrs.len(), 3);
2383        assert_eq!(c.addrs[0], Endpoint::new("::1", 9000));
2384        assert_eq!(c.addrs[1], Endpoint::new("h2", 9001));
2385        assert_eq!(c.addrs[2], Endpoint::new("2001:db8::5", 9000));
2386    }
2387
2388    #[test]
2389    fn ipv6_addr_missing_close_bracket_rejected() {
2390        let err = ReaderConfig::from_conf("ws::addr=[::1:9000").unwrap_err();
2391        assert_eq!(err.code(), ErrorCode::ConfigError);
2392    }
2393
2394    #[test]
2395    fn ipv6_addr_garbage_after_bracket_rejected() {
2396        let err = ReaderConfig::from_conf("ws::addr=[::1]junk").unwrap_err();
2397        assert_eq!(err.code(), ErrorCode::ConfigError);
2398    }
2399
2400    #[test]
2401    fn unbracketed_ipv6_rejected() {
2402        for bad in [
2403            "ws::addr=::1",
2404            "ws::addr=::1:9000",
2405            "ws::addr=2001:db8::1",
2406            "ws::addr=fe80::1%eth0",
2407            "ws::addr=h1:9000,::1:9001",
2408        ] {
2409            let err = ReaderConfig::from_conf(bad).unwrap_err();
2410            assert_eq!(
2411                err.code(),
2412                ErrorCode::ConfigError,
2413                "expected reject for {bad:?}"
2414            );
2415            let msg = err.msg();
2416            assert!(
2417                msg.contains("multiple ':'") || msg.contains("bracketed"),
2418                "expected diagnostic to mention bracketing, got {msg:?}"
2419            );
2420        }
2421    }
2422
2423    #[test]
2424    fn single_colon_host_port_still_accepted() {
2425        let c = ReaderConfig::from_conf("ws::addr=h1:9000").unwrap();
2426        assert_eq!(c.addrs[0], Endpoint::new("h1", 9000));
2427    }
2428
2429    #[test]
2430    fn url_for_uses_endpoint_display() {
2431        // `url_for` was migrated to format via `{ep}`. Lock the
2432        // common-case URL string so the migration didn't introduce
2433        // a regression for the predominant non-IPv6 path users see.
2434        let c = ReaderConfig::from_conf("ws::addr=db-a:9000;path=/exec").unwrap();
2435        assert_eq!(c.url_for(0), "ws://db-a:9000/exec");
2436    }
2437
2438    #[test]
2439    fn validate_accepts_parsed_default_config() {
2440        let c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2441        c.validate().expect("a freshly-parsed config must validate");
2442    }
2443
2444    #[test]
2445    fn validate_rejects_post_parse_backoff_overflow() {
2446        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2447        c.failover_backoff_max_ms = u64::MAX;
2448        let err = c.validate().unwrap_err();
2449        assert_eq!(err.code(), ErrorCode::ConfigError);
2450        assert!(
2451            err.msg().contains("failover_backoff_max_ms"),
2452            "got: {}",
2453            err.msg()
2454        );
2455    }
2456
2457    #[test]
2458    fn validate_rejects_post_parse_max_attempts_overflow() {
2459        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2460        c.failover_max_attempts = MAX_FAILOVER_MAX_ATTEMPTS + 1;
2461        let err = c.validate().unwrap_err();
2462        assert_eq!(err.code(), ErrorCode::ConfigError);
2463    }
2464
2465    #[test]
2466    fn validate_rejects_post_parse_max_attempts_zero() {
2467        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2468        c.failover_max_attempts = 0;
2469        let err = c.validate().unwrap_err();
2470        assert_eq!(err.code(), ErrorCode::ConfigError);
2471    }
2472
2473    #[test]
2474    fn validate_accepts_post_parse_backoff_zero_initial() {
2475        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2476        c.failover_backoff_initial_ms = 0;
2477        c.validate().unwrap();
2478    }
2479
2480    #[test]
2481    fn validate_rejects_post_parse_backoff_inversion() {
2482        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2483        c.failover_backoff_initial_ms = 1000;
2484        c.failover_backoff_max_ms = 50;
2485        let err = c.validate().unwrap_err();
2486        assert_eq!(err.code(), ErrorCode::ConfigError);
2487    }
2488
2489    #[test]
2490    fn validate_rejects_post_parse_max_version_out_of_range() {
2491        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2492        c.max_version = 0;
2493        let err = c.validate().unwrap_err();
2494        assert_eq!(err.code(), ErrorCode::ConfigError);
2495        c.max_version = HIGHEST_KNOWN_VERSION + 1;
2496        let err = c.validate().unwrap_err();
2497        assert_eq!(err.code(), ErrorCode::ConfigError);
2498    }
2499
2500    // ---------------------------------------------------------------
2501    // Post-parse string-field mutation: the parse-time CRLF /
2502    // control-byte guards must be re-applied by `validate()` so that
2503    // a hostile or careless caller can't bypass them by mutating the
2504    // `pub` fields after a clean `from_conf`. The threat is HTTP
2505    // header injection into the WS upgrade.
2506    // ---------------------------------------------------------------
2507
2508    #[test]
2509    fn validate_rejects_post_parse_client_id_with_crlf() {
2510        // Clean parse, then mutate to inject a CRLF + a forged
2511        // Authorization line into the X-QuestDB-Client-Id header.
2512        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2513        c.client_id = Some("foo\r\nAuthorization: Bearer attacker".into());
2514        let err = c.validate().unwrap_err();
2515        assert_eq!(err.code(), ErrorCode::ConfigError);
2516        assert!(
2517            err.msg().contains("client_id"),
2518            "error message must name the offending field; got: {}",
2519            err.msg()
2520        );
2521        // Bare LF and bare CR both rejected.
2522        c.client_id = Some("foo\nbar".into());
2523        assert_eq!(c.validate().unwrap_err().code(), ErrorCode::ConfigError);
2524        c.client_id = Some("foo\rbar".into());
2525        assert_eq!(c.validate().unwrap_err().code(), ErrorCode::ConfigError);
2526    }
2527
2528    #[test]
2529    fn validate_rejects_post_parse_zone_with_crlf() {
2530        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2531        c.zone = Some("eu-west-1a\r\nX-Injected: 1".into());
2532        let err = c.validate().unwrap_err();
2533        assert_eq!(err.code(), ErrorCode::ConfigError);
2534        assert!(err.msg().contains("zone"));
2535    }
2536
2537    #[test]
2538    fn validate_rejects_post_parse_verbatim_auth_with_control_bytes() {
2539        // Verbatim is the highest-risk variant: the value flows
2540        // unchanged into the `Authorization` header. The parse-time
2541        // `reject_control_bytes` lives in `AuthMode::from_parts`; the
2542        // `pub` `auth` field on ReaderConfig lets a caller skip that
2543        // path entirely.
2544        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2545        c.auth = AuthMode::Verbatim {
2546            value: "Bearer xx\r\nX-Injected: 1".into(),
2547        };
2548        let err = c.validate().unwrap_err();
2549        assert_eq!(err.code(), ErrorCode::AuthError);
2550        // Bare LF likewise.
2551        c.auth = AuthMode::Verbatim {
2552            value: "Bearer\nyy".into(),
2553        };
2554        assert_eq!(c.validate().unwrap_err().code(), ErrorCode::AuthError);
2555    }
2556
2557    #[test]
2558    fn validate_rejects_post_parse_bearer_token_with_control_bytes() {
2559        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2560        c.auth = AuthMode::Bearer {
2561            token: "abc\r\ndef".into(),
2562        };
2563        let err = c.validate().unwrap_err();
2564        assert_eq!(err.code(), ErrorCode::AuthError);
2565    }
2566
2567    #[test]
2568    fn validate_rejects_post_parse_basic_auth_with_control_bytes() {
2569        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2570        c.auth = AuthMode::Basic {
2571            username: "user\nfoo".into(),
2572            password: "pw".into(),
2573        };
2574        assert_eq!(c.validate().unwrap_err().code(), ErrorCode::AuthError);
2575        c.auth = AuthMode::Basic {
2576            username: "user".into(),
2577            password: "pw\r\nX-Injected: 1".into(),
2578        };
2579        assert_eq!(c.validate().unwrap_err().code(), ErrorCode::AuthError);
2580    }
2581
2582    #[test]
2583    fn validate_rejects_post_parse_basic_username_with_colon() {
2584        // The colon-in-username check ships in `from_parts` because
2585        // the server splits credentials on the first ':'. The same
2586        // hazard re-emerges if the caller assigns a Basic AuthMode
2587        // directly to the parsed config.
2588        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2589        c.auth = AuthMode::Basic {
2590            username: "admin:override".into(),
2591            password: "real".into(),
2592        };
2593        let err = c.validate().unwrap_err();
2594        assert_eq!(err.code(), ErrorCode::AuthError);
2595    }
2596
2597    #[test]
2598    fn validate_accepts_post_parse_clean_string_fields() {
2599        // Sanity counterpart: clean string fields after a clean parse
2600        // must still pass — the new validate hooks must not be
2601        // overzealous.
2602        let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
2603        c.client_id = Some("benign-id".into());
2604        c.zone = Some("eu-west-1a".into());
2605        c.auth = AuthMode::Bearer {
2606            token: "benign.token.value".into(),
2607        };
2608        c.validate().expect("clean string fields must validate");
2609    }
2610
2611    #[test]
2612    fn addr_comma_list_collects_all_endpoints() {
2613        let c = ReaderConfig::from_conf("ws::addr=h1:9000,h2:9001,h3:9002").unwrap();
2614        assert_eq!(
2615            c.addrs,
2616            vec![
2617                Endpoint::new("h1", 9000),
2618                Endpoint::new("h2", 9001),
2619                Endpoint::new("h3", 9002),
2620            ]
2621        );
2622    }
2623
2624    #[test]
2625    fn addr_repeated_key_collects_all_endpoints() {
2626        // Matches ingress: `addr=h1;addr=h2;...` must accumulate identically
2627        // to the comma form.
2628        let c = ReaderConfig::from_conf("ws::addr=h1:9000;addr=h2:9001;addr=h3:9002;").unwrap();
2629        assert_eq!(
2630            c.addrs,
2631            vec![
2632                Endpoint::new("h1", 9000),
2633                Endpoint::new("h2", 9001),
2634                Endpoint::new("h3", 9002),
2635            ]
2636        );
2637    }
2638
2639    #[test]
2640    fn addr_mixed_comma_and_repeated_key_collects_all_endpoints() {
2641        // The two forms must compose: a repeated key whose value is itself
2642        // a comma list flattens left-to-right.
2643        let c = ReaderConfig::from_conf("ws::addr=h1:9000,h2:9001;addr=h3:9002,h4:9003;").unwrap();
2644        assert_eq!(
2645            c.addrs,
2646            vec![
2647                Endpoint::new("h1", 9000),
2648                Endpoint::new("h2", 9001),
2649                Endpoint::new("h3", 9002),
2650                Endpoint::new("h4", 9003),
2651            ]
2652        );
2653    }
2654
2655    #[test]
2656    fn addr_repeated_key_rejects_empty_entry() {
2657        // Empty entries inside an addr= value must error per the
2658        // single-list contract.
2659        let err = ReaderConfig::from_conf("ws::addr=h1:9000;addr=,;addr=h2:9001;").unwrap_err();
2660        assert_eq!(err.code(), ErrorCode::ConfigError);
2661        assert!(
2662            err.msg().contains("Empty entry"),
2663            "unexpected msg: {}",
2664            err.msg()
2665        );
2666    }
2667
2668    #[test]
2669    fn addr_repeated_key_propagates_invalid_port() {
2670        // Diagnostic must still name the offending entry by its global
2671        // index across all addr= values, not per-value.
2672        let err = ReaderConfig::from_conf("ws::addr=h1:9000;addr=h2:notaport;").unwrap_err();
2673        assert_eq!(err.code(), ErrorCode::ConfigError);
2674        assert!(
2675            err.msg().contains("Invalid port in \"addr\" entry 1"),
2676            "unexpected msg: {}",
2677            err.msg()
2678        );
2679    }
2680}