use std::path::PathBuf;
use std::str::FromStr;
use crate::egress::auth::AuthMode;
use crate::error::{Result, fmt};
use crate::ingress::CertificateAuthority;
pub const DEFAULT_PATH: &str = "/read/v1";
pub const HIGHEST_KNOWN_VERSION: u8 = crate::egress::wire::PROTOCOL_VERSION;
const DEFAULT_PLAIN_PORT: &str = "9000";
const DEFAULT_TLS_PORT: &str = "9000";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Compression {
Raw,
Zstd,
Auto,
}
impl Compression {
pub fn accept_encoding(self, level: u8) -> String {
match self {
Compression::Raw => "raw".to_string(),
Compression::Zstd => format!("zstd;level={}", level),
Compression::Auto => format!("zstd;level={},raw", level),
}
}
pub fn header_token(self) -> &'static str {
match self {
Compression::Raw => "raw",
Compression::Zstd => "zstd",
Compression::Auto => "zstd,raw",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Target {
Any,
Primary,
Replica,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Endpoint {
pub host: String,
pub port: u16,
}
impl Endpoint {
pub fn new<S: Into<String>>(host: S, port: u16) -> Self {
Endpoint {
host: host.into(),
port,
}
}
}
impl std::fmt::Display for Endpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.host.contains(':') {
write!(f, "[{}]:{}", self.host, self.port)
} else {
write!(f, "{}:{}", self.host, self.port)
}
}
}
pub const DEFAULT_FAILOVER_ENABLED: bool = true;
pub const DEFAULT_FAILOVER_MAX_ATTEMPTS: u32 = 8;
pub const DEFAULT_FAILOVER_BACKOFF_INITIAL_MS: u64 = 50;
pub const DEFAULT_FAILOVER_BACKOFF_MAX_MS: u64 = 1_000;
pub const MAX_FAILOVER_MAX_ATTEMPTS: u32 = 1024;
pub const MAX_ADDRS: usize = 1024;
pub const MAX_FAILOVER_BACKOFF_MAX_MS: u64 = 60 * 60 * 1_000;
pub const DEFAULT_AUTH_TIMEOUT_MS: u64 = 15_000;
pub const MAX_AUTH_TIMEOUT_MS: u64 = 60 * 60 * 1_000;
pub const DEFAULT_SERVER_INFO_TIMEOUT_MS: u64 = 5_000;
pub const MAX_SERVER_INFO_TIMEOUT_MS: u64 = 60 * 60 * 1_000;
pub const DEFAULT_FAILOVER_MAX_DURATION_MS: u64 = 30_000;
pub const MAX_FAILOVER_MAX_DURATION_MS: u64 = 60 * 60 * 1_000;
pub const MAX_CONNECT_TIMEOUT_MS: u64 = 60 * 60 * 1_000;
pub const DEFAULT_COMPRESSION_LEVEL: u8 = 1;
pub const MIN_COMPRESSION_LEVEL: u8 = 1;
pub const MAX_COMPRESSION_LEVEL: u8 = 22;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TlsVerify {
On,
UnsafeOff,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ReaderConfig {
pub(crate) addrs: Vec<Endpoint>,
pub tls: bool,
pub path: String,
pub max_version: u8,
pub compression: Compression,
pub compression_level: u8,
pub max_batch_rows: u64,
pub client_id: Option<String>,
pub target: Target,
pub failover: bool,
pub failover_max_attempts: u32,
pub failover_backoff_initial_ms: u64,
pub failover_backoff_max_ms: u64,
pub failover_max_duration_ms: u64,
pub auth_timeout_ms: u64,
pub server_info_timeout_ms: u64,
pub connect_timeout_ms: u64,
pub zone: Option<String>,
pub auth: AuthMode,
pub tls_verify: TlsVerify,
pub tls_ca: CertificateAuthority,
pub tls_roots: Option<PathBuf>,
pub tls_roots_password: Option<String>,
}
pub(crate) const INGRESS_ONLY_CONFIG_KEYS: &[&str] = &[
"token_x",
"token_y",
"bind_interface",
"max_datagram_size",
"multicast_ttl",
"auto_flush",
"auto_flush_rows",
"auto_flush_bytes",
"auto_flush_interval",
"init_buf_size",
"max_buf_size",
"max_name_len",
"protocol_version",
"request_min_throughput",
"request_timeout",
"retry_timeout",
"retry_max_backoff_millis",
"auth_timeout",
"qwp_ws_progress",
"max_frame_rejections",
"poison_min_escalation_window_millis",
"sf_dir",
"sender_id",
"sf_max_segment_bytes",
"sf_max_total_bytes",
"sf_durability",
"sf_sync_interval_millis",
"sf_append_deadline_millis",
"reconnect_max_duration_millis",
"reconnect_initial_backoff_millis",
"reconnect_max_backoff_millis",
"initial_connect_retry",
"close_flush_timeout_millis",
"request_durable_ack",
"durable_ack_keepalive_interval_millis",
"drain_orphans",
"max_background_drainers",
"error_inbox_capacity",
"sender_pool_min",
"sender_pool_max",
"query_pool_min",
"query_pool_max",
"acquire_timeout_ms",
"idle_timeout_ms",
"lazy_connect",
"pool_reap",
];
impl ReaderConfig {
pub fn from_conf<T: AsRef<str>>(conf: T) -> Result<Self> {
let conf_str = conf.as_ref();
let addr_scan = crate::ingress::scan_qwp_ws_addr_params(conf_str)
.map_err(|e| fmt!(ConfigError, "{}", e.msg()))?;
let conf_to_parse = addr_scan
.as_ref()
.map(|s| s.sanitized_conf.as_str())
.unwrap_or(conf_str);
let conf = questdb_confstr::parse_conf_str(conf_to_parse)
.map_err(|e| fmt!(ConfigError, "Config parse error: {}", e))?;
let scheme = conf.service();
let tls = match scheme {
"ws" => false,
"wss" => true,
other => {
return Err(fmt!(
ConfigError,
"Unknown scheme \"{}\" — expected \"ws\" or \"wss\"",
other
));
}
};
let params = conf.params();
let addr_values: Vec<&str> = match &addr_scan {
Some(s) if !s.addr_values.is_empty() => {
s.addr_values.iter().map(String::as_str).collect()
}
_ => {
let addr = params.get("addr").ok_or_else(|| {
fmt!(ConfigError, "Missing \"addr\" parameter in config string")
})?;
vec![addr.as_str()]
}
};
let default_port = if tls {
DEFAULT_TLS_PORT
} else {
DEFAULT_PLAIN_PORT
};
let mut addrs: Vec<Endpoint> = Vec::new();
let mut i: usize = 0;
for addr in addr_values {
for entry in addr.split(',').map(str::trim) {
if entry.is_empty() {
return Err(fmt!(ConfigError, "Empty entry {} in \"addr\" list", i));
}
let (host, port_str) = if let Some(rest) = entry.strip_prefix('[') {
let close = rest.find(']').ok_or_else(|| {
fmt!(
ConfigError,
"Bracketed addr entry {} missing closing ']': {:?}",
i,
entry
)
})?;
let host = rest[..close].to_string();
let after = &rest[close + 1..];
let port_str = if after.is_empty() {
default_port.to_string()
} else if let Some(p) = after.strip_prefix(':') {
p.to_string()
} else {
return Err(fmt!(
ConfigError,
"Unexpected characters after ']' in addr entry {}: {:?}",
i,
entry
));
};
(host, port_str)
} else {
if entry.bytes().filter(|&b| b == b':').count() > 1 {
return Err(fmt!(
ConfigError,
"addr entry {} contains multiple ':' — IPv6 literals \
must be bracketed (e.g. [::1]:9000): {:?}",
i,
entry
));
}
match entry.rsplit_once(':') {
Some((h, p)) => (h.to_string(), p.to_string()),
None => (entry.to_string(), default_port.to_string()),
}
};
if host.is_empty() {
return Err(fmt!(
ConfigError,
"Empty host in \"addr\" entry {}: {:?}",
i,
entry
));
}
let port: u16 = port_str.parse().map_err(|_| {
fmt!(
ConfigError,
"Invalid port in \"addr\" entry {}: {:?}",
i,
entry
)
})?;
if port == 0 {
return Err(fmt!(
ConfigError,
"Port 0 is not a valid connect target in \"addr\" entry {}: {:?}",
i,
entry
));
}
addrs.push(Endpoint { host, port });
i += 1;
}
}
if addrs.is_empty() {
return Err(fmt!(ConfigError, "\"addr\" parameter is empty"));
}
if addrs.len() > MAX_ADDRS {
return Err(fmt!(
ConfigError,
"\"addr\" list length {} exceeds the hard cap of {}",
addrs.len(),
MAX_ADDRS
));
}
let mut path: String = DEFAULT_PATH.to_string();
let mut max_version: u8 = HIGHEST_KNOWN_VERSION;
let mut compression = Compression::Raw;
let mut compression_level: u8 = DEFAULT_COMPRESSION_LEVEL;
let mut max_batch_rows: u64 = 0;
let mut client_id: Option<String> = None;
let mut target = Target::Any;
let mut failover = DEFAULT_FAILOVER_ENABLED;
let mut failover_max_attempts: u32 = DEFAULT_FAILOVER_MAX_ATTEMPTS;
let mut failover_backoff_initial_ms: u64 = DEFAULT_FAILOVER_BACKOFF_INITIAL_MS;
let mut failover_backoff_max_ms: u64 = DEFAULT_FAILOVER_BACKOFF_MAX_MS;
let mut failover_max_duration_ms: u64 = DEFAULT_FAILOVER_MAX_DURATION_MS;
let mut auth_timeout_ms: u64 = DEFAULT_AUTH_TIMEOUT_MS;
let server_info_timeout_ms: u64 = DEFAULT_SERVER_INFO_TIMEOUT_MS;
let mut connect_timeout_ms: u64 = 0;
let mut zone: Option<String> = None;
let mut tls_verify = TlsVerify::On;
let mut tls_ca = default_tls_ca();
let mut tls_ca_explicit = false;
let mut tls_roots: Option<PathBuf> = None;
let mut tls_roots_password: Option<String> = None;
let mut username: Option<String> = None;
let mut password: Option<String> = None;
let mut token: Option<String> = None;
let mut auth_verbatim: Option<String> = None;
for (key, val) in params.iter() {
let key = key.as_str();
let val = val.as_str();
match key {
"addr" => {} "path" => {
if !val.starts_with('/') {
return Err(fmt!(
ConfigError,
"\"path\" must start with '/' (got {:?})",
val
));
}
path = val.to_string();
}
"max_version" => {
let v: u8 = parse_value("max_version", val)?;
if !(1..=HIGHEST_KNOWN_VERSION).contains(&v) {
return Err(fmt!(
ConfigError,
"\"max_version\" must be in 1..={} (got {})",
HIGHEST_KNOWN_VERSION,
v
));
}
max_version = v;
}
"compression" => {
compression = match val {
"raw" => Compression::Raw,
"zstd" => Compression::Zstd,
"auto" => Compression::Auto,
other => {
return Err(fmt!(
ConfigError,
"\"compression\" must be one of raw|zstd|auto (got {:?})",
other
));
}
};
}
"compression_level" => {
let v: u8 = parse_value("compression_level", val)?;
if !(MIN_COMPRESSION_LEVEL..=MAX_COMPRESSION_LEVEL).contains(&v) {
return Err(fmt!(
ConfigError,
"\"compression_level\" must be in {}..={} (got {})",
MIN_COMPRESSION_LEVEL,
MAX_COMPRESSION_LEVEL,
v
));
}
compression_level = v;
}
"max_batch_rows" => {
max_batch_rows = parse_value("max_batch_rows", val)?;
}
"client_id" => {
reject_crlf("client_id", val)?;
client_id = Some(val.to_string());
}
"target" => {
target = match val {
"any" => Target::Any,
"primary" => Target::Primary,
"replica" => Target::Replica,
other => {
return Err(fmt!(
ConfigError,
"\"target\" must be one of any|primary|replica (got {:?})",
other
));
}
};
}
"username" => username = Some(val.to_string()),
"password" => password = Some(val.to_string()),
"token" => token = Some(val.to_string()),
"auth" => auth_verbatim = Some(val.to_string()),
"tls_verify" => {
tls_verify = match val {
"on" => TlsVerify::On,
"unsafe_off" => TlsVerify::UnsafeOff,
other => {
return Err(fmt!(
ConfigError,
"\"tls_verify\" must be \"on\" or \"unsafe_off\" (got {:?})",
other
));
}
};
}
"tls_ca" => {
tls_ca = parse_tls_ca(val)?;
tls_ca_explicit = true;
}
"tls_roots" => {
let path = PathBuf::from_str(val).map_err(|e| {
fmt!(
ConfigError,
"Invalid path for \"tls_roots\" ({:?}): {}",
val,
e
)
})?;
tls_roots = Some(path);
}
"tls_roots_password" => {
tls_roots_password = Some(val.to_string());
}
"failover" => {
failover = parse_bool("failover", val)?;
}
"failover_max_attempts" => {
failover_max_attempts = parse_value("failover_max_attempts", val)?;
}
"failover_backoff_initial_ms" => {
failover_backoff_initial_ms = parse_value("failover_backoff_initial_ms", val)?;
}
"failover_backoff_max_ms" => {
failover_backoff_max_ms = parse_value("failover_backoff_max_ms", val)?;
}
"failover_max_duration_ms" => {
failover_max_duration_ms = parse_value("failover_max_duration_ms", val)?;
}
"auth_timeout_ms" => {
auth_timeout_ms = parse_value("auth_timeout_ms", val)?;
}
"connect_timeout" => {
connect_timeout_ms = parse_value("connect_timeout", val)?;
}
"zone" => {
reject_crlf("zone", val)?;
let trimmed = val.trim();
zone = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
}
"on_server_error" | "on_schema_error" | "on_parse_error" | "on_internal_error"
| "on_security_error" | "on_write_error" => {}
"buffer_pool_size" => {}
other if INGRESS_ONLY_CONFIG_KEYS.contains(&other) => {}
other => {
return Err(fmt!(ConfigError, "Unknown config key \"{}\"", other));
}
}
}
#[cfg(not(feature = "sync-reader-zstd"))]
{
if !matches!(compression, Compression::Raw) {
let user_token = match compression {
Compression::Raw => "raw",
Compression::Zstd => "zstd",
Compression::Auto => "auto",
};
return Err(fmt!(
ConfigError,
"\"compression={}\" requires the `sync-reader-zstd` crate feature; \
either enable it or use \"raw\"",
user_token
));
}
}
if !tls && (tls_roots.is_some() || tls_ca_explicit || tls_roots_password.is_some()) {
return Err(fmt!(
ConfigError,
"TLS-related keys require the \"wss\" scheme"
));
}
if tls_roots_password.is_some() && tls_roots.is_none() {
return Err(fmt!(
ConfigError,
"\"tls_roots_password\" requires \"tls_roots\" \
(the password unlocks the keystore at that path)"
));
}
if tls_roots.is_some() {
if tls_ca_explicit && tls_ca != CertificateAuthority::PemFile {
return Err(fmt!(
ConfigError,
"\"tls_roots\" requires \"tls_ca=pem_file\" (or omit \"tls_ca\")"
));
}
tls_ca = CertificateAuthority::PemFile;
}
let auth = AuthMode::from_parts(
username.as_deref(),
password.as_deref(),
token.as_deref(),
auth_verbatim.as_deref(),
)?;
let cfg = ReaderConfig {
addrs,
tls,
path,
max_version,
compression,
compression_level,
max_batch_rows,
client_id,
target,
failover,
failover_max_attempts,
failover_backoff_initial_ms,
failover_backoff_max_ms,
failover_max_duration_ms,
auth_timeout_ms,
server_info_timeout_ms,
connect_timeout_ms,
zone,
auth,
tls_verify,
tls_ca,
tls_roots,
tls_roots_password,
};
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<()> {
if self.addrs.is_empty() {
return Err(fmt!(ConfigError, "\"addr\" parameter is empty"));
}
if self.addrs.len() > MAX_ADDRS {
return Err(fmt!(
ConfigError,
"\"addr\" list length {} exceeds the hard cap of {}",
self.addrs.len(),
MAX_ADDRS
));
}
if !(1..=HIGHEST_KNOWN_VERSION).contains(&self.max_version) {
return Err(fmt!(
ConfigError,
"\"max_version\" must be in 1..={} (got {})",
HIGHEST_KNOWN_VERSION,
self.max_version
));
}
if !(MIN_COMPRESSION_LEVEL..=MAX_COMPRESSION_LEVEL).contains(&self.compression_level) {
return Err(fmt!(
ConfigError,
"\"compression_level\" must be in {}..={} (got {})",
MIN_COMPRESSION_LEVEL,
MAX_COMPRESSION_LEVEL,
self.compression_level
));
}
if self.failover_max_attempts == 0 {
return Err(fmt!(
ConfigError,
"\"failover_max_attempts\" must be >= 1 (use \"failover=off\" to disable failover entirely)"
));
}
if self.failover_max_attempts > MAX_FAILOVER_MAX_ATTEMPTS {
return Err(fmt!(
ConfigError,
"\"failover_max_attempts\" {} exceeds the hard cap of {}",
self.failover_max_attempts,
MAX_FAILOVER_MAX_ATTEMPTS
));
}
if self.failover_backoff_max_ms < self.failover_backoff_initial_ms {
return Err(fmt!(
ConfigError,
"\"failover_backoff_max_ms\" ({}) must be >= \"failover_backoff_initial_ms\" ({})",
self.failover_backoff_max_ms,
self.failover_backoff_initial_ms
));
}
if self.failover_backoff_max_ms > MAX_FAILOVER_BACKOFF_MAX_MS {
return Err(fmt!(
ConfigError,
"\"failover_backoff_max_ms\" {} exceeds the hard cap of {} (1 hour)",
self.failover_backoff_max_ms,
MAX_FAILOVER_BACKOFF_MAX_MS
));
}
if self.failover_max_duration_ms > MAX_FAILOVER_MAX_DURATION_MS {
return Err(fmt!(
ConfigError,
"\"failover_max_duration_ms\" {} exceeds the hard cap of {} (1 hour)",
self.failover_max_duration_ms,
MAX_FAILOVER_MAX_DURATION_MS
));
}
if self.auth_timeout_ms == 0 {
return Err(fmt!(
ConfigError,
"\"auth_timeout_ms\" must be > 0 (no sentinel for \"unbounded\" — \
set a value high enough for your slowest peer's upgrade response)"
));
}
if self.auth_timeout_ms > MAX_AUTH_TIMEOUT_MS {
return Err(fmt!(
ConfigError,
"\"auth_timeout_ms\" {} exceeds the hard cap of {} (1 hour)",
self.auth_timeout_ms,
MAX_AUTH_TIMEOUT_MS
));
}
if self.server_info_timeout_ms == 0 {
return Err(fmt!(ConfigError, "\"server_info_timeout_ms\" must be > 0"));
}
if self.server_info_timeout_ms > MAX_SERVER_INFO_TIMEOUT_MS {
return Err(fmt!(
ConfigError,
"\"server_info_timeout_ms\" {} exceeds the hard cap of {} (1 hour)",
self.server_info_timeout_ms,
MAX_SERVER_INFO_TIMEOUT_MS
));
}
if self.connect_timeout_ms > MAX_CONNECT_TIMEOUT_MS {
return Err(fmt!(
ConfigError,
"\"connect_timeout\" {} exceeds the hard cap of {} (1 hour)",
self.connect_timeout_ms,
MAX_CONNECT_TIMEOUT_MS
));
}
if let Some(id) = &self.client_id {
reject_crlf("client_id", id)?;
}
if let Some(z) = &self.zone {
reject_crlf("zone", z)?;
}
self.auth.validate()?;
#[cfg(not(feature = "insecure-skip-verify"))]
if matches!(self.tls_verify, TlsVerify::UnsafeOff) {
return Err(fmt!(
ConfigError,
"\"tls_verify=unsafe_off\" requires the \"insecure-skip-verify\" crate feature"
));
}
Ok(())
}
pub(crate) fn failover_reconnect_rounds(&self) -> u32 {
self.failover_max_attempts.saturating_sub(1)
}
pub fn addrs(&self) -> &[Endpoint] {
&self.addrs
}
pub fn url_for(&self, idx: usize) -> String {
let ep = &self.addrs[idx];
let scheme = if self.tls { "wss" } else { "ws" };
format!("{}://{}{}", scheme, ep, self.path)
}
pub fn url(&self) -> String {
self.url_for(0)
}
pub fn upgrade_headers(&self) -> Vec<(&'static str, String)> {
let mut headers = Vec::with_capacity(8);
headers.push(("X-QWP-Max-Version", self.max_version.to_string()));
if let Some(id) = &self.client_id {
headers.push(("X-QWP-Client-Id", id.clone()));
}
headers.push((
"X-QWP-Accept-Encoding",
self.compression.accept_encoding(self.compression_level),
));
if self.max_batch_rows > 0 {
headers.push(("X-QWP-Max-Batch-Rows", self.max_batch_rows.to_string()));
}
if let Some(v) = self.auth.header_value() {
headers.push(("Authorization", v));
}
headers
}
}
fn default_tls_ca() -> CertificateAuthority {
#[cfg(feature = "tls-webpki-certs")]
{
CertificateAuthority::WebpkiRoots
}
#[cfg(all(not(feature = "tls-webpki-certs"), feature = "tls-native-certs"))]
{
CertificateAuthority::OsRoots
}
#[cfg(not(any(feature = "tls-webpki-certs", feature = "tls-native-certs")))]
{
CertificateAuthority::PemFile
}
}
fn parse_tls_ca(val: &str) -> Result<CertificateAuthority> {
Ok(match val {
#[cfg(feature = "tls-webpki-certs")]
"webpki_roots" => CertificateAuthority::WebpkiRoots,
#[cfg(not(feature = "tls-webpki-certs"))]
"webpki_roots" => {
return Err(fmt!(
ConfigError,
"\"tls_ca=webpki_roots\" requires the \"tls-webpki-certs\" feature"
));
}
#[cfg(feature = "tls-native-certs")]
"os_roots" => CertificateAuthority::OsRoots,
#[cfg(not(feature = "tls-native-certs"))]
"os_roots" => {
return Err(fmt!(
ConfigError,
"\"tls_ca=os_roots\" requires the \"tls-native-certs\" feature"
));
}
#[cfg(all(feature = "tls-webpki-certs", feature = "tls-native-certs"))]
"webpki_and_os_roots" => CertificateAuthority::WebpkiAndOsRoots,
#[cfg(not(all(feature = "tls-webpki-certs", feature = "tls-native-certs")))]
"webpki_and_os_roots" => {
return Err(fmt!(
ConfigError,
"\"tls_ca=webpki_and_os_roots\" requires both the \"tls-webpki-certs\" and \"tls-native-certs\" features"
));
}
"pem_file" => CertificateAuthority::PemFile,
other => {
return Err(fmt!(
ConfigError,
"\"tls_ca\" must be one of webpki_roots|os_roots|webpki_and_os_roots|pem_file (got {:?})",
other
));
}
})
}
fn parse_value<T>(name: &str, raw: &str) -> Result<T>
where
T: FromStr,
{
raw.parse::<T>()
.map_err(|_| fmt!(ConfigError, "Could not parse \"{}\" value: {:?}", name, raw))
}
fn parse_bool(name: &str, raw: &str) -> Result<bool> {
match raw {
"true" | "on" | "yes" | "1" => Ok(true),
"false" | "off" | "no" | "0" => Ok(false),
_ => Err(fmt!(
ConfigError,
"\"{}\" must be a boolean (got {:?})",
name,
raw
)),
}
}
fn reject_crlf(name: &str, val: &str) -> Result<()> {
if val.contains('\n') || val.contains('\r') {
return Err(fmt!(ConfigError, "\"{}\" must not contain CR or LF", name));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ErrorCode;
#[test]
fn minimal_plain_conf() {
let c = ReaderConfig::from_conf("ws::addr=localhost:9000").unwrap();
assert_eq!(c.addrs.len(), 1);
assert_eq!(c.addrs[0], Endpoint::new("localhost", 9000));
assert!(!c.tls);
assert_eq!(c.path, DEFAULT_PATH);
assert_eq!(c.max_version, HIGHEST_KNOWN_VERSION);
assert_eq!(c.compression, Compression::Raw);
assert_eq!(c.url(), "ws://localhost:9000/read/v1");
}
#[test]
fn tls_scheme_changes_url() {
let c = ReaderConfig::from_conf("wss::addr=h:8443").unwrap();
assert!(c.tls);
assert_eq!(c.url(), "wss://h:8443/read/v1");
}
#[test]
fn ws_scheme_is_plain() {
let c = ReaderConfig::from_conf("ws::addr=localhost:9000").unwrap();
assert!(!c.tls);
assert_eq!(c.url(), "ws://localhost:9000/read/v1");
}
#[test]
fn wss_scheme_is_tls() {
let c = ReaderConfig::from_conf("wss::addr=h:8443").unwrap();
assert!(c.tls);
assert_eq!(c.url(), "wss://h:8443/read/v1");
}
#[test]
fn unknown_scheme_rejected() {
let err = ReaderConfig::from_conf("http::addr=h:1").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn missing_addr_rejected() {
let err = ReaderConfig::from_conf("ws::path=/read/v1").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn unknown_key_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h:1;mystery=x").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn basic_auth_in_conf() {
let c = ReaderConfig::from_conf("ws::addr=h:1;username=admin;password=quest").unwrap();
assert_eq!(
c.auth.header_value(),
Some("Basic YWRtaW46cXVlc3Q=".to_string())
);
}
#[test]
fn bearer_in_conf() {
let c = ReaderConfig::from_conf("ws::addr=h:1;token=tok").unwrap();
assert_eq!(c.auth.header_value(), Some("Bearer tok".to_string()));
}
#[test]
fn auth_modes_mutually_exclusive() {
let err =
ReaderConfig::from_conf("ws::addr=h:1;username=u;password=p;token=t").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[cfg(not(feature = "sync-reader-zstd"))]
#[test]
fn compression_zstd_rejected_without_feature() {
let err = ReaderConfig::from_conf("ws::addr=h:1;compression=zstd").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
let err = ReaderConfig::from_conf("ws::addr=h:1;compression=auto").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[cfg(feature = "sync-reader-zstd")]
#[test]
fn compression_zstd_accepted_with_feature() {
let c = ReaderConfig::from_conf("ws::addr=h:1;compression=zstd").unwrap();
assert_eq!(c.compression, Compression::Zstd);
let c = ReaderConfig::from_conf("ws::addr=h:1;compression=auto").unwrap();
assert_eq!(c.compression, Compression::Auto);
}
#[test]
fn invalid_compression_value() {
let err = ReaderConfig::from_conf("ws::addr=h:1;compression=xyz").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn compression_level_default_is_one() {
let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
assert_eq!(c.compression_level, DEFAULT_COMPRESSION_LEVEL);
assert_eq!(c.compression_level, 1);
}
#[cfg(feature = "sync-reader-zstd")]
#[test]
fn compression_level_parses_and_is_emitted() {
let c =
ReaderConfig::from_conf("ws::addr=h:1;compression=zstd;compression_level=9").unwrap();
assert_eq!(c.compression_level, 9);
let headers = c.upgrade_headers();
let accept = headers
.iter()
.find(|(n, _)| *n == "X-QWP-Accept-Encoding")
.expect("accept-encoding header present");
assert_eq!(accept.1, "zstd;level=9");
}
#[cfg(feature = "sync-reader-zstd")]
#[test]
fn compression_level_emitted_for_auto() {
let c =
ReaderConfig::from_conf("ws::addr=h:1;compression=auto;compression_level=7").unwrap();
let headers = c.upgrade_headers();
let accept = headers
.iter()
.find(|(n, _)| *n == "X-QWP-Accept-Encoding")
.expect("accept-encoding header present");
assert_eq!(accept.1, "zstd;level=7,raw");
}
#[test]
fn compression_level_ignored_for_raw() {
let c = ReaderConfig::from_conf("ws::addr=h:1;compression_level=15").unwrap();
let headers = c.upgrade_headers();
let accept = headers
.iter()
.find(|(n, _)| *n == "X-QWP-Accept-Encoding")
.expect("accept-encoding header present");
assert_eq!(accept.1, "raw");
}
#[test]
fn compression_level_out_of_range_rejected() {
for bad in ["0", "23", "100"] {
let err = ReaderConfig::from_conf(format!("ws::addr=h:1;compression_level={}", bad))
.unwrap_err();
assert_eq!(
err.code(),
ErrorCode::ConfigError,
"compression_level={} must be rejected",
bad
);
}
}
#[test]
fn compression_level_accepts_full_range() {
for ok in [
MIN_COMPRESSION_LEVEL,
DEFAULT_COMPRESSION_LEVEL,
MAX_COMPRESSION_LEVEL,
] {
let c = ReaderConfig::from_conf(format!("ws::addr=h:1;compression_level={}", ok))
.expect("level in-range");
assert_eq!(c.compression_level, ok);
}
}
#[test]
fn target_parses() {
let c = ReaderConfig::from_conf("ws::addr=h:1;target=primary").unwrap();
assert_eq!(c.target, Target::Primary);
}
#[test]
fn multi_addr_parses() {
let c = ReaderConfig::from_conf("ws::addr=h1:9000,h2:9001,h3,h4:9999;").unwrap();
assert_eq!(c.addrs.len(), 4);
assert_eq!(c.addrs[0], Endpoint::new("h1", 9000));
assert_eq!(c.addrs[1], Endpoint::new("h2", 9001));
assert_eq!(c.addrs[2], Endpoint::new("h3", 9000)); assert_eq!(c.addrs[3], Endpoint::new("h4", 9999));
}
#[test]
fn empty_addr_entry_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h1:9000,,h2:9001;").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn target_invalid_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h:1;target=leader").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn upgrade_headers_default() {
let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
let h = c.upgrade_headers();
assert_eq!(h.len(), 2);
assert_eq!(h[0], ("X-QWP-Max-Version", "1".to_string()));
assert_eq!(h[1], ("X-QWP-Accept-Encoding", "raw".to_string()));
}
#[test]
fn upgrade_headers_full_set() {
let c = ReaderConfig::from_conf(
"ws::addr=h:1;client_id=app1;max_batch_rows=1000;username=u;password=p",
)
.unwrap();
let h = c.upgrade_headers();
let names: Vec<_> = h.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"X-QWP-Max-Version"));
assert!(names.contains(&"X-QWP-Client-Id"));
assert!(names.contains(&"X-QWP-Accept-Encoding"));
assert!(names.contains(&"X-QWP-Max-Batch-Rows"));
assert!(names.contains(&"Authorization"));
assert!(!names.contains(&"X-QWP-Request-Durable-Ack"));
let c = ReaderConfig::from_conf("ws::addr=h:1;max_batch_rows=0").unwrap();
let h = c.upgrade_headers();
assert!(h.iter().all(|(n, _)| *n != "X-QWP-Max-Batch-Rows"));
}
#[test]
fn path_must_start_with_slash() {
let err = ReaderConfig::from_conf("ws::addr=h:1;path=read/v1").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn default_port_when_omitted() {
let c = ReaderConfig::from_conf("ws::addr=localhost").unwrap();
assert_eq!(c.addrs[0].port, 9000);
}
#[test]
fn invalid_port_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h:notaport").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn port_zero_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h:0").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("Port 0"),
"diagnostic must name the offending value; got: {}",
err.msg()
);
let err = ReaderConfig::from_conf("ws::addr=a:9000,b:0").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
let err = ReaderConfig::from_conf("ws::addr=[::1]:0").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn tls_keys_with_plain_scheme_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h:1;tls_roots=/tmp/x").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
let err = ReaderConfig::from_conf("ws::addr=h:1;tls_ca=pem_file").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[cfg(not(feature = "insecure-skip-verify"))]
#[test]
fn validate_rejects_unsafe_off_when_feature_disabled() {
let err = ReaderConfig::from_conf("wss::addr=h:1;tls_verify=unsafe_off").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("insecure-skip-verify"),
"msg: {}",
err.msg()
);
let mut cfg = ReaderConfig::from_conf("wss::addr=h:1").unwrap();
assert_eq!(cfg.tls_verify, TlsVerify::On);
cfg.tls_verify = TlsVerify::UnsafeOff;
let err = cfg.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("insecure-skip-verify"),
"msg: {}",
err.msg()
);
}
#[cfg(feature = "insecure-skip-verify")]
#[test]
fn validate_accepts_unsafe_off_when_feature_enabled() {
let cfg = ReaderConfig::from_conf("wss::addr=h:1;tls_verify=unsafe_off").unwrap();
assert_eq!(cfg.tls_verify, TlsVerify::UnsafeOff);
cfg.validate()
.expect("unsafe_off must pass validate when feature is on");
}
#[test]
fn tls_roots_password_without_tls_roots_rejected() {
let err = ReaderConfig::from_conf("wss::addr=h:1;tls_roots_password=secret").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("tls_roots_password") && err.msg().contains("tls_roots"),
"msg: {}",
err.msg()
);
}
#[test]
fn tls_roots_password_without_tls_scheme_rejected() {
let err =
ReaderConfig::from_conf("ws::addr=h:1;tls_roots=/tmp/r;tls_roots_password=secret")
.unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn tls_roots_password_with_tls_roots_accepted() {
let c = ReaderConfig::from_conf(
"wss::addr=h:1;tls_roots=/path/to/store.jks;tls_roots_password=secret",
)
.unwrap();
assert_eq!(c.tls_ca, CertificateAuthority::PemFile);
assert_eq!(c.tls_roots_password.as_deref(), Some("secret"));
}
#[test]
fn tls_roots_implies_pem_file_ca() {
let c = ReaderConfig::from_conf("wss::addr=h:1;tls_roots=/path/to/roots.pem").unwrap();
assert_eq!(c.tls_ca, CertificateAuthority::PemFile);
assert_eq!(
c.tls_roots.as_deref(),
Some(std::path::Path::new("/path/to/roots.pem"))
);
}
#[test]
fn tls_roots_with_conflicting_ca_rejected() {
#[cfg(feature = "tls-webpki-certs")]
{
let err = ReaderConfig::from_conf("wss::addr=h:1;tls_ca=webpki_roots;tls_roots=/tmp/x")
.unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
}
#[test]
fn tls_ca_pem_file_explicit() {
let c =
ReaderConfig::from_conf("wss::addr=h:1;tls_ca=pem_file;tls_roots=/tmp/r.pem").unwrap();
assert_eq!(c.tls_ca, CertificateAuthority::PemFile);
}
#[test]
fn tls_ca_invalid_value_rejected() {
let err = ReaderConfig::from_conf("wss::addr=h:1;tls_ca=mystery").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[cfg(feature = "tls-webpki-certs")]
#[test]
fn tls_ca_webpki_roots_default() {
let c = ReaderConfig::from_conf("wss::addr=h:1").unwrap();
assert_eq!(c.tls_ca, CertificateAuthority::WebpkiRoots);
assert_eq!(c.tls_roots, None);
}
#[test]
fn durable_ack_key_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h:1;durable_ack=true").unwrap_err();
assert!(
err.msg().to_lowercase().contains("durable_ack")
|| err.msg().to_lowercase().contains("unknown")
);
}
#[test]
fn failover_defaults() {
let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
assert!(c.failover);
assert_eq!(c.failover_max_attempts, DEFAULT_FAILOVER_MAX_ATTEMPTS);
assert_eq!(
c.failover_backoff_initial_ms,
DEFAULT_FAILOVER_BACKOFF_INITIAL_MS
);
assert_eq!(c.failover_backoff_max_ms, DEFAULT_FAILOVER_BACKOFF_MAX_MS);
}
#[test]
fn failover_keys_parsed() {
let c = ReaderConfig::from_conf(
"ws::addr=h:1;failover=off;failover_max_attempts=3;failover_backoff_initial_ms=100;failover_backoff_max_ms=2000",
)
.unwrap();
assert!(!c.failover);
assert_eq!(c.failover_max_attempts, 3);
assert_eq!(c.failover_backoff_initial_ms, 100);
assert_eq!(c.failover_backoff_max_ms, 2000);
}
#[test]
fn failover_backoff_initial_zero_disables_sleep() {
let c = ReaderConfig::from_conf("ws::addr=h:1;failover_backoff_initial_ms=0").unwrap();
assert_eq!(c.failover_backoff_initial_ms, 0);
assert_eq!(c.failover_backoff_max_ms, DEFAULT_FAILOVER_BACKOFF_MAX_MS);
}
#[test]
fn failover_backoff_max_below_initial_rejected() {
let err = ReaderConfig::from_conf(
"ws::addr=h:1;failover_backoff_initial_ms=500;failover_backoff_max_ms=100",
)
.unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn failover_invalid_attempts_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h:1;failover_max_attempts=abc").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn failover_max_attempts_above_cap_rejected() {
let conf = format!(
"ws::addr=h:1;failover_max_attempts={}",
MAX_FAILOVER_MAX_ATTEMPTS + 1
);
let err = ReaderConfig::from_conf(&conf).unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("exceeds the hard cap"));
}
#[test]
fn failover_max_attempts_at_cap_accepted() {
let conf = format!(
"ws::addr=h:1;failover_max_attempts={}",
MAX_FAILOVER_MAX_ATTEMPTS
);
let c = ReaderConfig::from_conf(&conf).unwrap();
assert_eq!(c.failover_max_attempts, MAX_FAILOVER_MAX_ATTEMPTS);
}
#[test]
fn failover_backoff_max_above_cap_rejected() {
let conf = format!(
"ws::addr=h:1;failover_backoff_initial_ms=1;failover_backoff_max_ms={}",
MAX_FAILOVER_BACKOFF_MAX_MS + 1
);
let err = ReaderConfig::from_conf(&conf).unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("exceeds the hard cap"),
"msg: {}",
err.msg()
);
}
#[test]
fn failover_backoff_max_at_cap_accepted() {
let conf = format!(
"ws::addr=h:1;failover_backoff_initial_ms=1;failover_backoff_max_ms={}",
MAX_FAILOVER_BACKOFF_MAX_MS
);
let c = ReaderConfig::from_conf(&conf).unwrap();
assert_eq!(c.failover_backoff_max_ms, MAX_FAILOVER_BACKOFF_MAX_MS);
}
#[test]
fn zone_unset_is_none_by_default() {
let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
assert_eq!(c.zone, None);
}
#[test]
fn zone_parses() {
let c = ReaderConfig::from_conf("ws::addr=h:1;zone=eu-west-1a").unwrap();
assert_eq!(c.zone.as_deref(), Some("eu-west-1a"));
}
#[test]
fn zone_empty_or_whitespace_normalises_to_none() {
let c = ReaderConfig::from_conf("ws::addr=h:1;zone=").unwrap();
assert_eq!(c.zone, None, "empty value collapses to unset");
let c = ReaderConfig::from_conf("ws::addr=h:1;zone= ").unwrap();
assert_eq!(c.zone, None, "whitespace-only collapses to unset");
}
#[test]
fn zone_trims_value() {
let c = ReaderConfig::from_conf("ws::addr=h:1;zone= eu-west-1a ").unwrap();
assert_eq!(c.zone.as_deref(), Some("eu-west-1a"));
}
#[test]
fn zone_rejects_cr_lf() {
let err = ReaderConfig::from_conf("ws::addr=h:1;zone=eu\nwest").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
let err = ReaderConfig::from_conf("ws::addr=h:1;zone=eu\rwest").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn auth_timeout_defaults_to_15s() {
let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
assert_eq!(c.auth_timeout_ms, DEFAULT_AUTH_TIMEOUT_MS);
assert_eq!(DEFAULT_AUTH_TIMEOUT_MS, 15_000);
}
#[test]
fn auth_timeout_parses() {
let c = ReaderConfig::from_conf("ws::addr=h:1;auth_timeout_ms=3000").unwrap();
assert_eq!(c.auth_timeout_ms, 3_000);
}
#[test]
fn auth_timeout_zero_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h:1;auth_timeout_ms=0").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("auth_timeout_ms"), "msg: {}", err.msg());
}
#[test]
fn auth_timeout_above_cap_rejected() {
let conf = format!("ws::addr=h:1;auth_timeout_ms={}", MAX_AUTH_TIMEOUT_MS + 1);
let err = ReaderConfig::from_conf(&conf).unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("exceeds the hard cap"));
}
#[test]
fn auth_timeout_at_cap_accepted() {
let conf = format!("ws::addr=h:1;auth_timeout_ms={}", MAX_AUTH_TIMEOUT_MS);
let c = ReaderConfig::from_conf(&conf).unwrap();
assert_eq!(c.auth_timeout_ms, MAX_AUTH_TIMEOUT_MS);
}
#[test]
fn connect_timeout_defaults_to_os_default() {
let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
assert_eq!(
c.connect_timeout_ms, 0,
"default is the OS-default dial (0)"
);
}
#[test]
fn connect_timeout_parses_from_connect_string() {
let c = ReaderConfig::from_conf("ws::addr=h:1;connect_timeout=250").unwrap();
assert_eq!(c.connect_timeout_ms, 250);
}
#[test]
fn connect_timeout_zero_is_os_default() {
let c = ReaderConfig::from_conf("ws::addr=h:1;connect_timeout=0").unwrap();
assert_eq!(c.connect_timeout_ms, 0);
}
#[test]
fn connect_timeout_at_cap_accepted() {
let conf = format!("ws::addr=h:1;connect_timeout={}", MAX_CONNECT_TIMEOUT_MS);
let c = ReaderConfig::from_conf(&conf).unwrap();
assert_eq!(c.connect_timeout_ms, MAX_CONNECT_TIMEOUT_MS);
}
#[test]
fn connect_timeout_above_cap_rejected() {
let conf = format!(
"ws::addr=h:1;connect_timeout={}",
MAX_CONNECT_TIMEOUT_MS + 1
);
let err = ReaderConfig::from_conf(&conf).unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("connect_timeout"), "msg: {}", err.msg());
}
#[test]
fn failover_max_duration_defaults_to_30s() {
let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
assert_eq!(c.failover_max_duration_ms, DEFAULT_FAILOVER_MAX_DURATION_MS);
assert_eq!(DEFAULT_FAILOVER_MAX_DURATION_MS, 30_000);
}
#[test]
fn failover_max_duration_parses() {
let c = ReaderConfig::from_conf("ws::addr=h:1;failover_max_duration_ms=60000").unwrap();
assert_eq!(c.failover_max_duration_ms, 60_000);
}
#[test]
fn failover_max_duration_zero_is_unbounded() {
let c = ReaderConfig::from_conf("ws::addr=h:1;failover_max_duration_ms=0").unwrap();
assert_eq!(c.failover_max_duration_ms, 0);
}
#[test]
fn failover_max_duration_above_cap_rejected() {
let conf = format!(
"ws::addr=h:1;failover_max_duration_ms={}",
MAX_FAILOVER_MAX_DURATION_MS + 1
);
let err = ReaderConfig::from_conf(&conf).unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("exceeds the hard cap"));
}
#[test]
fn server_info_timeout_defaults_to_5s() {
let c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
assert_eq!(c.server_info_timeout_ms, DEFAULT_SERVER_INFO_TIMEOUT_MS);
assert_eq!(DEFAULT_SERVER_INFO_TIMEOUT_MS, 5_000);
}
#[test]
fn server_info_timeout_is_not_parsed_from_connect_string() {
let err = ReaderConfig::from_conf("ws::addr=h:1;server_info_timeout_ms=1000").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("Unknown config key"),
"msg: {}",
err.msg()
);
}
const RESERVED_ON_ERROR_KEYS: &[&str] = &[
"on_server_error",
"on_schema_error",
"on_parse_error",
"on_internal_error",
"on_security_error",
"on_write_error",
];
#[test]
fn reserved_on_error_policy_keys_all_together_are_accepted_silently() {
let conf = "ws::addr=h:1\
;on_server_error=halt\
;on_schema_error=drop\
;on_parse_error=halt\
;on_internal_error=halt\
;on_security_error=halt\
;on_write_error=drop";
let c = ReaderConfig::from_conf(conf).unwrap();
assert_eq!(c.addrs.len(), 1);
assert_eq!(c.addrs[0].host, "h");
assert_eq!(c.addrs[0].port, 1);
}
#[test]
fn reserved_on_error_policy_keys_each_accepted_individually() {
for key in RESERVED_ON_ERROR_KEYS {
let conf = format!("ws::addr=h:1;{key}=halt");
ReaderConfig::from_conf(&conf)
.unwrap_or_else(|e| panic!("expected {key:?} to parse, got {}", e.msg()));
}
}
#[test]
fn reserved_on_error_policy_keys_accept_any_value_without_validation() {
for key in RESERVED_ON_ERROR_KEYS {
for val in ["halt", "drop", "auto", "anything", ""] {
let conf = format!("ws::addr=h:1;{key}={val}");
ReaderConfig::from_conf(&conf)
.unwrap_or_else(|e| panic!("expected {key}={val:?} to parse, got {}", e.msg()));
}
}
}
#[test]
fn reserved_on_error_policy_keys_do_not_swallow_other_settings() {
let conf = "ws::addr=h:1;on_schema_error=drop;target=primary;zone=eu-1";
let c = ReaderConfig::from_conf(conf).unwrap();
assert_eq!(c.target, Target::Primary);
assert_eq!(c.zone.as_deref(), Some("eu-1"));
}
#[test]
fn reserved_on_error_policy_keys_typo_still_rejected() {
for typo in [
"on_server_err",
"on_schema_errors",
"on_parse",
"On_Write_Error",
] {
let conf = format!("ws::addr=h:1;{typo}=halt");
let err = ReaderConfig::from_conf(&conf)
.err()
.unwrap_or_else(|| panic!("expected {typo:?} to be rejected"));
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("Unknown config key"),
"typo {typo:?}: msg: {}",
err.msg()
);
}
}
#[test]
fn reserved_buffer_pool_size_accepts_any_value_without_validation() {
for val in ["1", "4", "1024", "0", "-1", "not-a-number", ""] {
let conf = format!("ws::addr=h:1;buffer_pool_size={val}");
ReaderConfig::from_conf(&conf).unwrap_or_else(|e| {
panic!(
"expected buffer_pool_size={val:?} to parse, got {}",
e.msg()
)
});
}
}
#[test]
fn reserved_buffer_pool_size_does_not_swallow_other_settings() {
let conf = "ws::addr=h:1;buffer_pool_size=8;target=replica;zone=us-2";
let c = ReaderConfig::from_conf(conf).unwrap();
assert_eq!(c.target, Target::Replica);
assert_eq!(c.zone.as_deref(), Some("us-2"));
}
#[test]
fn egress_silently_accepts_every_ingress_only_key() {
for key in INGRESS_ONLY_CONFIG_KEYS {
for val in ["1", "off", "anything", ""] {
let conf = format!("ws::addr=h:1;{key}={val}");
ReaderConfig::from_conf(&conf).unwrap_or_else(|e| {
panic!(
"expected egress to silently accept ingress-only \
key {key}={val:?}, got {}",
e.msg()
)
});
}
}
}
#[test]
fn egress_rejects_removed_in_flight_keys() {
for key in ["in_flight_window", "max_in_flight"] {
let conf = format!("ws::addr=h:1;{key}=8");
let err = ReaderConfig::from_conf(&conf).unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError, "key: {key}");
assert!(
err.msg().contains(&format!("Unknown config key \"{key}\"")),
"key: {key}, msg: {}",
err.msg()
);
}
}
#[test]
fn egress_accepts_full_ingress_connect_string_unchanged() {
let conf = "ws::addr=h:9000\
;username=u;password=p\
;init_buf_size=65536;max_buf_size=1048576;max_name_len=127\
;auto_flush=off;auto_flush_rows=1000\
;protocol_version=2\
;tls_verify=on\
;target=primary;zone=eu-west-1a";
let c = ReaderConfig::from_conf(conf).unwrap();
assert_eq!(c.addrs.len(), 1);
assert_eq!(c.addrs[0], Endpoint::new("h", 9000));
assert_eq!(c.target, Target::Primary);
assert_eq!(c.zone.as_deref(), Some("eu-west-1a"));
assert!(matches!(c.auth, AuthMode::Basic { .. }));
}
#[test]
fn reserved_buffer_pool_size_typo_still_rejected() {
for typo in [
"buffer_pool",
"buffer_pool_sizes",
"Buffer_Pool_Size",
"buffer_size",
] {
let conf = format!("ws::addr=h:1;{typo}=4");
let err = ReaderConfig::from_conf(&conf)
.err()
.unwrap_or_else(|| panic!("expected {typo:?} to be rejected"));
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("Unknown config key"),
"typo {typo:?}: msg: {}",
err.msg()
);
}
}
#[test]
fn server_info_timeout_zero_rejected_by_validate() {
let mut c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
c.server_info_timeout_ms = 0;
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("server_info_timeout_ms"));
}
#[test]
fn server_info_timeout_above_cap_rejected_by_validate() {
let mut c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
c.server_info_timeout_ms = MAX_SERVER_INFO_TIMEOUT_MS + 1;
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("exceeds the hard cap"));
}
#[test]
fn server_info_timeout_at_cap_accepted() {
let mut c = ReaderConfig::from_conf("ws::addr=h:1").unwrap();
c.server_info_timeout_ms = MAX_SERVER_INFO_TIMEOUT_MS;
c.validate().unwrap();
}
#[test]
fn addrs_above_cap_rejected() {
let mut addr = String::from("ws::addr=");
for i in 0..(MAX_ADDRS + 1) {
if i > 0 {
addr.push(',');
}
addr.push_str(&format!("h{}:9000", i));
}
let err = ReaderConfig::from_conf(&addr).unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("exceeds the hard cap"),
"msg: {}",
err.msg()
);
}
#[test]
fn failover_max_attempts_zero_rejected() {
let err = ReaderConfig::from_conf("ws::addr=h:1;failover_max_attempts=0").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("failover_max_attempts"),
"msg: {}",
err.msg()
);
}
#[test]
fn failover_max_attempts_counts_initial_execute_attempt() {
let c = ReaderConfig::from_conf("ws::addr=h:1;failover_max_attempts=1").unwrap();
assert_eq!(c.failover_reconnect_rounds(), 0);
let c = ReaderConfig::from_conf("ws::addr=h:1;failover_max_attempts=8").unwrap();
assert_eq!(c.failover_reconnect_rounds(), 7);
}
#[test]
fn endpoint_display_common_cases() {
assert_eq!(
Endpoint::new("localhost", 9000).to_string(),
"localhost:9000"
);
assert_eq!(Endpoint::new("db-a", 9000).to_string(), "db-a:9000");
assert_eq!(
Endpoint::new("127.0.0.1", 9000).to_string(),
"127.0.0.1:9000"
);
let ep = Endpoint::new("example.com", 1234);
let conf = format!("ws::addr={}", ep);
let parsed = ReaderConfig::from_conf(&conf).expect("parse round-trip");
assert_eq!(parsed.addrs(), &[ep]);
}
#[test]
fn endpoint_display_ipv6_brackets() {
assert_eq!(Endpoint::new("::1", 9000).to_string(), "[::1]:9000");
assert_eq!(
Endpoint::new("2001:db8::1", 443).to_string(),
"[2001:db8::1]:443"
);
}
#[test]
fn ipv6_addr_parses_with_explicit_port() {
let c = ReaderConfig::from_conf("ws::addr=[::1]:9000").unwrap();
assert_eq!(c.addrs.len(), 1);
assert_eq!(c.addrs[0], Endpoint::new("::1", 9000));
assert_eq!(c.url_for(0), "ws://[::1]:9000/read/v1");
}
#[test]
fn ipv6_addr_default_port() {
let c = ReaderConfig::from_conf("ws::addr=[2001:db8::1]").unwrap();
assert_eq!(c.addrs[0], Endpoint::new("2001:db8::1", 9000));
assert_eq!(c.url_for(0), "ws://[2001:db8::1]:9000/read/v1");
}
#[test]
fn ipv6_addr_in_multi_addr_list() {
let c = ReaderConfig::from_conf("ws::addr=[::1]:9000,h2:9001,[2001:db8::5]").unwrap();
assert_eq!(c.addrs.len(), 3);
assert_eq!(c.addrs[0], Endpoint::new("::1", 9000));
assert_eq!(c.addrs[1], Endpoint::new("h2", 9001));
assert_eq!(c.addrs[2], Endpoint::new("2001:db8::5", 9000));
}
#[test]
fn ipv6_addr_missing_close_bracket_rejected() {
let err = ReaderConfig::from_conf("ws::addr=[::1:9000").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn ipv6_addr_garbage_after_bracket_rejected() {
let err = ReaderConfig::from_conf("ws::addr=[::1]junk").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn unbracketed_ipv6_rejected() {
for bad in [
"ws::addr=::1",
"ws::addr=::1:9000",
"ws::addr=2001:db8::1",
"ws::addr=fe80::1%eth0",
"ws::addr=h1:9000,::1:9001",
] {
let err = ReaderConfig::from_conf(bad).unwrap_err();
assert_eq!(
err.code(),
ErrorCode::ConfigError,
"expected reject for {bad:?}"
);
let msg = err.msg();
assert!(
msg.contains("multiple ':'") || msg.contains("bracketed"),
"expected diagnostic to mention bracketing, got {msg:?}"
);
}
}
#[test]
fn single_colon_host_port_still_accepted() {
let c = ReaderConfig::from_conf("ws::addr=h1:9000").unwrap();
assert_eq!(c.addrs[0], Endpoint::new("h1", 9000));
}
#[test]
fn url_for_uses_endpoint_display() {
let c = ReaderConfig::from_conf("ws::addr=db-a:9000;path=/exec").unwrap();
assert_eq!(c.url_for(0), "ws://db-a:9000/exec");
}
#[test]
fn validate_accepts_parsed_default_config() {
let c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.validate().expect("a freshly-parsed config must validate");
}
#[test]
fn validate_rejects_post_parse_backoff_overflow() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.failover_backoff_max_ms = u64::MAX;
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("failover_backoff_max_ms"),
"got: {}",
err.msg()
);
}
#[test]
fn validate_rejects_post_parse_max_attempts_overflow() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.failover_max_attempts = MAX_FAILOVER_MAX_ATTEMPTS + 1;
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn validate_rejects_post_parse_max_attempts_zero() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.failover_max_attempts = 0;
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn validate_accepts_post_parse_backoff_zero_initial() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.failover_backoff_initial_ms = 0;
c.validate().unwrap();
}
#[test]
fn validate_rejects_post_parse_backoff_inversion() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.failover_backoff_initial_ms = 1000;
c.failover_backoff_max_ms = 50;
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn validate_rejects_post_parse_max_version_out_of_range() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.max_version = 0;
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
c.max_version = HIGHEST_KNOWN_VERSION + 1;
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
}
#[test]
fn validate_rejects_post_parse_client_id_with_crlf() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.client_id = Some("foo\r\nAuthorization: Bearer attacker".into());
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("client_id"),
"error message must name the offending field; got: {}",
err.msg()
);
c.client_id = Some("foo\nbar".into());
assert_eq!(c.validate().unwrap_err().code(), ErrorCode::ConfigError);
c.client_id = Some("foo\rbar".into());
assert_eq!(c.validate().unwrap_err().code(), ErrorCode::ConfigError);
}
#[test]
fn validate_rejects_post_parse_zone_with_crlf() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.zone = Some("eu-west-1a\r\nX-Injected: 1".into());
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(err.msg().contains("zone"));
}
#[test]
fn validate_rejects_post_parse_verbatim_auth_with_control_bytes() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.auth = AuthMode::Verbatim {
value: "Bearer xx\r\nX-Injected: 1".into(),
};
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::AuthError);
c.auth = AuthMode::Verbatim {
value: "Bearer\nyy".into(),
};
assert_eq!(c.validate().unwrap_err().code(), ErrorCode::AuthError);
}
#[test]
fn validate_rejects_post_parse_bearer_token_with_control_bytes() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.auth = AuthMode::Bearer {
token: "abc\r\ndef".into(),
};
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::AuthError);
}
#[test]
fn validate_rejects_post_parse_basic_auth_with_control_bytes() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.auth = AuthMode::Basic {
username: "user\nfoo".into(),
password: "pw".into(),
};
assert_eq!(c.validate().unwrap_err().code(), ErrorCode::AuthError);
c.auth = AuthMode::Basic {
username: "user".into(),
password: "pw\r\nX-Injected: 1".into(),
};
assert_eq!(c.validate().unwrap_err().code(), ErrorCode::AuthError);
}
#[test]
fn validate_rejects_post_parse_basic_username_with_colon() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.auth = AuthMode::Basic {
username: "admin:override".into(),
password: "real".into(),
};
let err = c.validate().unwrap_err();
assert_eq!(err.code(), ErrorCode::AuthError);
}
#[test]
fn validate_accepts_post_parse_clean_string_fields() {
let mut c = ReaderConfig::from_conf("ws::addr=h:9000").unwrap();
c.client_id = Some("benign-id".into());
c.zone = Some("eu-west-1a".into());
c.auth = AuthMode::Bearer {
token: "benign.token.value".into(),
};
c.validate().expect("clean string fields must validate");
}
#[test]
fn addr_comma_list_collects_all_endpoints() {
let c = ReaderConfig::from_conf("ws::addr=h1:9000,h2:9001,h3:9002").unwrap();
assert_eq!(
c.addrs,
vec![
Endpoint::new("h1", 9000),
Endpoint::new("h2", 9001),
Endpoint::new("h3", 9002),
]
);
}
#[test]
fn addr_repeated_key_collects_all_endpoints() {
let c = ReaderConfig::from_conf("ws::addr=h1:9000;addr=h2:9001;addr=h3:9002;").unwrap();
assert_eq!(
c.addrs,
vec![
Endpoint::new("h1", 9000),
Endpoint::new("h2", 9001),
Endpoint::new("h3", 9002),
]
);
}
#[test]
fn addr_mixed_comma_and_repeated_key_collects_all_endpoints() {
let c = ReaderConfig::from_conf("ws::addr=h1:9000,h2:9001;addr=h3:9002,h4:9003;").unwrap();
assert_eq!(
c.addrs,
vec![
Endpoint::new("h1", 9000),
Endpoint::new("h2", 9001),
Endpoint::new("h3", 9002),
Endpoint::new("h4", 9003),
]
);
}
#[test]
fn addr_repeated_key_rejects_empty_entry() {
let err = ReaderConfig::from_conf("ws::addr=h1:9000;addr=,;addr=h2:9001;").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("Empty entry"),
"unexpected msg: {}",
err.msg()
);
}
#[test]
fn addr_repeated_key_propagates_invalid_port() {
let err = ReaderConfig::from_conf("ws::addr=h1:9000;addr=h2:notaport;").unwrap_err();
assert_eq!(err.code(), ErrorCode::ConfigError);
assert!(
err.msg().contains("Invalid port in \"addr\" entry 1"),
"unexpected msg: {}",
err.msg()
);
}
}