Skip to main content

heliosdb_proxy/
config.rs

1//! Proxy Configuration
2//!
3//! Configuration management for HeliosDB Proxy.
4
5use crate::{ProxyError, Result};
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use std::time::Duration;
9
10// =============================================================================
11// POOL MODE TYPES
12// =============================================================================
13
14/// Connection pooling mode
15///
16/// Determines when connections are returned to the pool.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum PoolingMode {
20    /// Session mode: 1:1 client-to-backend mapping
21    #[default]
22    Session,
23    /// Transaction mode: Return after COMMIT/ROLLBACK
24    Transaction,
25    /// Statement mode: Return after each statement
26    Statement,
27}
28
29/// Prepared statement handling mode
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "lowercase")]
32pub enum PreparedStatementMode {
33    /// Disable prepared statements
34    #[default]
35    Disable,
36    /// Track and recreate on new connections
37    Track,
38    /// Use protocol-level named statements
39    Named,
40}
41
42/// Pool mode configuration
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PoolModeConfig {
45    /// Default pooling mode
46    #[serde(default)]
47    pub mode: PoolingMode,
48    /// Maximum connections per node
49    #[serde(default = "default_pool_mode_max_size")]
50    pub max_pool_size: u32,
51    /// Minimum idle connections
52    #[serde(default = "default_pool_mode_min_idle")]
53    pub min_idle: u32,
54    /// Idle timeout (seconds)
55    #[serde(default = "default_pool_mode_idle_timeout")]
56    pub idle_timeout_secs: u64,
57    /// Max connection lifetime (seconds)
58    #[serde(default = "default_pool_mode_max_lifetime")]
59    pub max_lifetime_secs: u64,
60    /// Acquire timeout (seconds)
61    #[serde(default = "default_pool_mode_acquire_timeout")]
62    pub acquire_timeout_secs: u64,
63    /// Reset query to run when returning connection to pool
64    #[serde(default = "default_reset_query")]
65    pub reset_query: String,
66    /// Prepared statement mode
67    #[serde(default)]
68    pub prepared_statement_mode: PreparedStatementMode,
69    /// Conditional reset (Transaction/Statement pooling): when true, a
70    /// connection that provably touched no session state (no `SET`/GUC, temp
71    /// table, prepared statement, `LISTEN`, advisory lock, …) is parked WITHOUT
72    /// running `reset_query`, saving a backend round-trip per clean transaction.
73    /// Classification is conservative — anything not provably session-neutral
74    /// still runs the full reset — so a misclassification only ever costs an
75    /// unnecessary reset, never leaks state. Off by default; intended for
76    /// autocommit / simple-protocol workloads. See the `stmt_leaves_session_state`
77    /// classifier for the exact (documented) limitation around session-setting
78    /// user functions.
79    #[serde(default)]
80    pub skip_clean_reset: bool,
81}
82
83fn default_pool_mode_max_size() -> u32 {
84    100
85}
86
87fn default_pool_mode_min_idle() -> u32 {
88    10
89}
90
91fn default_pool_mode_idle_timeout() -> u64 {
92    600
93}
94
95fn default_pool_mode_max_lifetime() -> u64 {
96    3600
97}
98
99fn default_pool_mode_acquire_timeout() -> u64 {
100    5
101}
102
103fn default_reset_query() -> String {
104    "DISCARD ALL".to_string()
105}
106
107impl Default for PoolModeConfig {
108    fn default() -> Self {
109        Self {
110            mode: PoolingMode::default(),
111            max_pool_size: default_pool_mode_max_size(),
112            min_idle: default_pool_mode_min_idle(),
113            idle_timeout_secs: default_pool_mode_idle_timeout(),
114            max_lifetime_secs: default_pool_mode_max_lifetime(),
115            acquire_timeout_secs: default_pool_mode_acquire_timeout(),
116            reset_query: default_reset_query(),
117            prepared_statement_mode: PreparedStatementMode::default(),
118            skip_clean_reset: false,
119        }
120    }
121}
122
123impl PoolModeConfig {
124    /// Create config for session mode
125    pub fn session_mode() -> Self {
126        Self {
127            mode: PoolingMode::Session,
128            prepared_statement_mode: PreparedStatementMode::Named,
129            ..Default::default()
130        }
131    }
132
133    /// Create config for transaction mode
134    pub fn transaction_mode() -> Self {
135        Self {
136            mode: PoolingMode::Transaction,
137            prepared_statement_mode: PreparedStatementMode::Track,
138            ..Default::default()
139        }
140    }
141
142    /// Create config for statement mode
143    pub fn statement_mode() -> Self {
144        Self {
145            mode: PoolingMode::Statement,
146            prepared_statement_mode: PreparedStatementMode::Disable,
147            ..Default::default()
148        }
149    }
150
151    /// Get idle timeout as Duration
152    pub fn idle_timeout(&self) -> Duration {
153        Duration::from_secs(self.idle_timeout_secs)
154    }
155
156    /// Get max lifetime as Duration
157    pub fn max_lifetime(&self) -> Duration {
158        Duration::from_secs(self.max_lifetime_secs)
159    }
160
161    /// Get acquire timeout as Duration
162    pub fn acquire_timeout(&self) -> Duration {
163        Duration::from_secs(self.acquire_timeout_secs)
164    }
165}
166
167// =============================================================================
168// MAIN PROXY CONFIG
169// =============================================================================
170
171/// Proxy configuration
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ProxyConfig {
174    /// Listen address for client connections
175    pub listen_address: String,
176    /// Admin API address
177    pub admin_address: String,
178    /// Bearer token required on admin API requests. When set, every admin
179    /// endpoint except liveness probes (`/health*`, `/livez`, `/readyz`)
180    /// requires `Authorization: Bearer <token>`. Absent (default) = open on
181    /// loopback only — the proxy refuses to start with a non-loopback
182    /// `admin_address` and no token (see `validate`), because the admin API runs
183    /// privileged operations (arbitrary SQL, forced failover, cutover, branch
184    /// DROP DATABASE, replay against arbitrary targets).
185    #[serde(default)]
186    pub admin_token: Option<String>,
187    /// Explicit opt-in to expose the admin API on a non-loopback address WITHOUT
188    /// a token. Default `false` — leave it so unless you front the admin port
189    /// with your own authenticating proxy/network policy.
190    #[serde(default)]
191    pub admin_allow_insecure: bool,
192    /// Enable TR (Transaction Replay)
193    pub tr_enabled: bool,
194    /// TR mode
195    pub tr_mode: TrMode,
196    /// Connection pool configuration
197    pub pool: PoolConfig,
198    /// Pool mode configuration (Session/Transaction/Statement)
199    #[serde(default)]
200    pub pool_mode: PoolModeConfig,
201    /// Load balancer configuration
202    pub load_balancer: LoadBalancerConfig,
203    /// Health check configuration
204    pub health: HealthConfig,
205    /// Backend nodes
206    pub nodes: Vec<NodeConfig>,
207    /// TLS configuration
208    pub tls: Option<TlsConfig>,
209    /// Write timeout during failover (seconds)
210    /// When primary is unavailable, wait this long for a new primary before returning error
211    #[serde(default = "default_write_timeout_secs")]
212    pub write_timeout_secs: u64,
213    /// Plugin system configuration. Only consumed when the `wasm-plugins`
214    /// feature is enabled; on a feature-off build, values are parsed and
215    /// ignored so existing configs don't break.
216    #[serde(default)]
217    pub plugins: PluginToml,
218    /// pg_hba-style connection admission rules, evaluated in order before any
219    /// backend connection is opened. Empty (the default) means admit all
220    /// (current behaviour preserved).
221    #[serde(default)]
222    pub hba: Vec<HbaRule>,
223    /// Client authentication mode. Absent/default = pass-through (the proxy
224    /// relays the client's auth to the backend, current behaviour).
225    #[serde(default)]
226    pub auth: AuthConfig,
227    /// MCP (Model Context Protocol) agent gateway. Disabled by default.
228    #[serde(default)]
229    pub mcp: McpConfig,
230    /// Per-agent SQL contracts (scoped grants). Referenced by id from the
231    /// MCP gateway (`[mcp] contract`). Empty by default.
232    #[serde(default)]
233    pub agent_contracts: Vec<crate::agent_contract::AgentContract>,
234    /// HTTP SQL gateway (Neon-serverless-driver compatible). Disabled by
235    /// default — lets edge/serverless clients run SQL over HTTP.
236    #[serde(default)]
237    pub http_gateway: HttpGatewayConfig,
238    /// Continuous traffic mirroring to a secondary backend. Disabled by
239    /// default — the on-ramp to a PG->Nano migration mirror.
240    #[serde(default)]
241    pub mirror: MirrorConfig,
242    /// Edge / geo proxy mode (role=home caches reads + broadcasts
243    /// invalidations to subscribed edges; role=edge serves reads from a
244    /// local cache and forwards misses/writes to the home). Disabled by
245    /// default. Parsed on every build so configs round-trip, but
246    /// `enabled = true` requires the `edge-proxy` compile-time feature —
247    /// `validate()` rejects it otherwise.
248    #[serde(default)]
249    pub edge: crate::edge::EdgeConfig,
250    /// Instant branch databases. Disabled by default — provisions
251    /// CREATE DATABASE ... TEMPLATE clones through the proxy.
252    #[serde(default)]
253    pub branch: BranchConfig,
254    /// SQL-comment routing hints (`/*helios:route=primary*/`). Disabled by
255    /// default — when enabled, the proxy parses hints from query SQL and
256    /// applies them as a route override that wins over the default verb
257    /// routing (but never over a plugin `Block`). Only consumed when the
258    /// `routing-hints` feature is compiled in; parsed-and-ignored otherwise.
259    #[serde(default)]
260    pub routing_hints: RoutingHintsConfig,
261    /// Multi-dimensional rate limiting (token bucket + concurrency). Disabled
262    /// by default. Only enforced when the `rate-limiting` feature is compiled
263    /// in; parsed-and-ignored otherwise.
264    #[serde(default)]
265    pub rate_limit: RateLimitToml,
266    /// Per-node circuit breaker (trip failing backends out of rotation,
267    /// fast-fail while open). Disabled by default. Only enforced when the
268    /// `circuit-breaker` feature is compiled in.
269    #[serde(default)]
270    pub circuit_breaker: CircuitBreakerToml,
271    /// Query analytics (fingerprinting, per-query statistics, slow-query log,
272    /// pattern detection). Disabled by default. Only active when the
273    /// `query-analytics` feature is compiled in.
274    #[serde(default)]
275    pub analytics: AnalyticsToml,
276    /// In-process anomaly detector tunables (rate-spike z-score, credential-
277    /// stuffing burst window, novel-query fingerprint cap, event ring buffer).
278    /// Parsed on every build so configs round-trip; only consumed when the
279    /// `anomaly-detection` feature is compiled in. Defaults reproduce the
280    /// detector's historical hardcoded behaviour exactly.
281    #[serde(default)]
282    pub anomaly: AnomalyToml,
283    /// Replica-lag-aware routing + read-your-writes. Disabled by default. Only
284    /// enforced when the `lag-routing` feature is compiled in.
285    #[serde(default)]
286    pub lag_routing: LagRoutingToml,
287    /// Query-result cache (L1 hot / L2 warm). Disabled by default. Only active
288    /// when the `query-cache` feature is compiled in.
289    #[serde(default)]
290    pub cache: CacheToml,
291    /// SQL query rewriting (rules engine). Disabled by default. Only active
292    /// when the `query-rewriting` feature is compiled in.
293    #[serde(default)]
294    pub query_rewrite: QueryRewriteToml,
295    /// Multi-tenancy (per-tenant row isolation via injected predicates).
296    /// Disabled by default. Only active when the `multi-tenancy` feature is
297    /// compiled in.
298    #[serde(default)]
299    pub multi_tenancy: MultiTenancyToml,
300    /// Schema/workload-aware routing (route OLAP queries to an analytics node).
301    /// Disabled by default. Only active when the `schema-routing` feature is on.
302    #[serde(default)]
303    pub schema_routing: SchemaRoutingToml,
304    /// GraphQL-to-SQL gateway (separate HTTP listener). Disabled by default.
305    /// Only active when the `graphql-gateway` feature is compiled in.
306    #[serde(default)]
307    pub graphql_gateway: GraphqlGatewayConfig,
308    /// Proxy-side unnamed-`Parse` promotion (Batch H). When a client re-sends an
309    /// identical unnamed extended `Parse` (the dominant pgbench/ORM pattern),
310    /// the proxy skips forwarding it to a backend that already holds that exact
311    /// unnamed statement and synthesizes the `ParseComplete` locally — cutting
312    /// the per-cycle re-`Parse` overhead. Default on; a kill-switch for drivers
313    /// that somehow depend on the redundant round trip.
314    #[serde(default = "default_true")]
315    pub optimize_unnamed_parse: bool,
316    /// How long a graceful binary-handoff drain (SIGUSR2) keeps serving
317    /// in-flight connections before the old process exits (Batch H). After this
318    /// many seconds, any still-open connections are dropped so the handoff
319    /// completes in bounded time. Overridable at runtime via the
320    /// `HELIOS_DRAIN_TIMEOUT_SECS` env var.
321    #[serde(default = "default_drain_timeout_secs")]
322    pub shutdown_drain_timeout_secs: u64,
323    /// Operational safety limits and timeouts for the PG-wire data path
324    /// (cancel-key map size, handshake/read/write deadlines, per-session
325    /// prepared-statement and buffer caps, idle-pool ceiling + reaper cadence).
326    /// Every key defaults to the value it had as a hardcoded constant, so a
327    /// config without a `[limits]` block is byte-for-byte unchanged.
328    #[serde(default)]
329    pub limits: LimitsToml,
330}
331
332fn default_drain_timeout_secs() -> u64 {
333    60
334}
335
336/// Branch-database configuration: the maintenance connection the proxy uses
337/// to provision `CREATE DATABASE <branch> TEMPLATE <base>` clones.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct BranchConfig {
340    #[serde(default)]
341    pub enabled: bool,
342    #[serde(default = "default_localhost")]
343    pub backend_host: String,
344    #[serde(default = "default_pg_port")]
345    pub backend_port: u16,
346    /// A role with CREATEDB privilege.
347    #[serde(default = "default_pg_user")]
348    pub admin_user: String,
349    pub admin_password: Option<String>,
350    /// Maintenance database to issue CREATE/DROP DATABASE against (not the
351    /// branch itself). Defaults to "postgres".
352    #[serde(default = "default_admin_db")]
353    pub admin_database: String,
354    /// Default template database to branch from when a request omits `base`.
355    #[serde(default = "default_admin_db")]
356    pub base_database: String,
357}
358
359impl Default for BranchConfig {
360    fn default() -> Self {
361        Self {
362            enabled: false,
363            backend_host: default_localhost(),
364            backend_port: default_pg_port(),
365            admin_user: default_pg_user(),
366            admin_password: None,
367            admin_database: default_admin_db(),
368            base_database: default_admin_db(),
369        }
370    }
371}
372
373fn default_admin_db() -> String {
374    "postgres".to_string()
375}
376
377/// Traffic-mirror configuration: replay a sampled share of live (simple-query)
378/// writes to a secondary backend, asynchronously and off the client hot path.
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct MirrorConfig {
381    #[serde(default)]
382    pub enabled: bool,
383    /// Fraction of eligible statements to mirror, 0.0..=1.0.
384    #[serde(default = "default_sample_rate")]
385    pub sample_rate: f64,
386    /// Mirror only write/DDL statements (default). When false, all simple
387    /// queries are mirrored.
388    #[serde(default = "default_true_bool")]
389    pub writes_only: bool,
390    /// Bounded queue depth; when full, statements are dropped (and counted)
391    /// rather than blocking the client path.
392    #[serde(default = "default_mirror_queue")]
393    pub queue_size: usize,
394    #[serde(default = "default_localhost")]
395    pub backend_host: String,
396    #[serde(default = "default_pg_port")]
397    pub backend_port: u16,
398    #[serde(default = "default_pg_user")]
399    pub backend_user: String,
400    pub backend_password: Option<String>,
401    pub backend_database: Option<String>,
402    /// Source (primary) connection used by `POST /api/migration/snapshot` to
403    /// read existing data when bootstrapping the secondary. Defaults mirror
404    /// the listener-side backend; set explicitly for a snapshot.
405    #[serde(default = "default_localhost")]
406    pub source_host: String,
407    #[serde(default = "default_pg_port")]
408    pub source_port: u16,
409    #[serde(default = "default_pg_user")]
410    pub source_user: String,
411    pub source_password: Option<String>,
412    pub source_database: Option<String>,
413}
414
415impl Default for MirrorConfig {
416    fn default() -> Self {
417        Self {
418            enabled: false,
419            sample_rate: 1.0,
420            writes_only: true,
421            queue_size: 10_000,
422            backend_host: default_localhost(),
423            backend_port: default_pg_port(),
424            backend_user: default_pg_user(),
425            backend_password: None,
426            backend_database: None,
427            source_host: default_localhost(),
428            source_port: default_pg_port(),
429            source_user: default_pg_user(),
430            source_password: None,
431            source_database: None,
432        }
433    }
434}
435
436fn default_sample_rate() -> f64 {
437    1.0
438}
439fn default_mirror_queue() -> usize {
440    10_000
441}
442
443/// HTTP SQL gateway configuration. A Neon-`@neondatabase/serverless`-style
444/// `POST /sql` endpoint that runs one statement over the backend PG-wire
445/// client and returns `{ command, rowCount, rows, fields }`.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct HttpGatewayConfig {
448    #[serde(default)]
449    pub enabled: bool,
450    #[serde(default = "default_http_gw_listen")]
451    pub listen_address: String,
452    #[serde(default = "default_localhost")]
453    pub backend_host: String,
454    #[serde(default = "default_pg_port")]
455    pub backend_port: u16,
456    #[serde(default = "default_pg_user")]
457    pub backend_user: String,
458    pub backend_password: Option<String>,
459    pub backend_database: Option<String>,
460    /// Optional Bearer token required on requests.
461    #[serde(default)]
462    pub auth_token: Option<String>,
463}
464
465impl Default for HttpGatewayConfig {
466    fn default() -> Self {
467        Self {
468            enabled: false,
469            listen_address: default_http_gw_listen(),
470            backend_host: default_localhost(),
471            backend_port: default_pg_port(),
472            backend_user: default_pg_user(),
473            backend_password: None,
474            backend_database: None,
475            auth_token: None,
476        }
477    }
478}
479
480fn default_http_gw_listen() -> String {
481    "127.0.0.1:9093".to_string()
482}
483
484/// MCP agent-gateway configuration. When enabled, the proxy exposes a native
485/// MCP server so AI agents call `query`/`list_tables`/`explain` tools instead
486/// of opening raw SQL connections — each call gated by the gateway's policy
487/// (read-only by default) and logged.
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct McpConfig {
490    #[serde(default)]
491    pub enabled: bool,
492    /// HTTP listen address for the MCP JSON-RPC endpoint.
493    #[serde(default = "default_mcp_listen")]
494    pub listen_address: String,
495    /// Backend the gateway runs tool SQL against.
496    #[serde(default = "default_localhost")]
497    pub backend_host: String,
498    #[serde(default = "default_pg_port")]
499    pub backend_port: u16,
500    #[serde(default = "default_pg_user")]
501    pub backend_user: String,
502    pub backend_password: Option<String>,
503    pub backend_database: Option<String>,
504    /// When true (default), the gateway refuses write/DDL statements — agents
505    /// get a read-only database surface.
506    #[serde(default = "default_true_bool")]
507    pub read_only: bool,
508    /// Name of an `[[agent_contracts]]` entry to enforce on every tool call
509    /// (scoped grants + repair hints). None = only the `read_only` guardrail.
510    #[serde(default)]
511    pub contract: Option<String>,
512    /// Bearer token required on every MCP request. When set, a request without
513    /// `Authorization: Bearer <token>` is rejected. Absent (default) = open, so
514    /// set this for any non-loopback deployment — like the HTTP/GraphQL
515    /// gateways, MCP exposes SQL and must not be anonymous off localhost.
516    #[serde(default)]
517    pub auth_token: Option<String>,
518}
519
520impl Default for McpConfig {
521    fn default() -> Self {
522        Self {
523            enabled: false,
524            listen_address: default_mcp_listen(),
525            backend_host: default_localhost(),
526            backend_port: default_pg_port(),
527            backend_user: default_pg_user(),
528            backend_password: None,
529            backend_database: None,
530            read_only: true,
531            contract: None,
532            auth_token: None,
533        }
534    }
535}
536
537fn default_mcp_listen() -> String {
538    "127.0.0.1:9092".to_string()
539}
540fn default_localhost() -> String {
541    "127.0.0.1".to_string()
542}
543fn default_pg_port() -> u16 {
544    5432
545}
546fn default_pg_user() -> String {
547    "postgres".to_string()
548}
549fn default_true_bool() -> bool {
550    true
551}
552
553/// Client-side authentication configuration.
554#[derive(Debug, Clone, Serialize, Deserialize, Default)]
555pub struct AuthConfig {
556    /// `passthrough` (default) relays client auth to the backend.
557    /// `scram` makes the proxy terminate SCRAM-SHA-256 itself against
558    /// `auth_file`, becoming the auth boundary (foundation for pooling).
559    #[serde(default)]
560    pub mode: AuthMode,
561    /// Path to a pgbouncer-style user list (`user:secret`, secret = plaintext
562    /// or a `SCRAM-SHA-256$...` verifier). Required when `mode = "scram"`.
563    #[serde(default)]
564    pub auth_file: Option<String>,
565}
566
567/// Proxy client-authentication mode.
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
569#[serde(rename_all = "lowercase")]
570pub enum AuthMode {
571    /// Relay the client's auth exchange straight to the backend.
572    #[default]
573    Passthrough,
574    /// Terminate SCRAM-SHA-256 at the proxy against `auth_file`.
575    Scram,
576}
577
578/// A single pg_hba-style admission rule. The first rule whose `user`,
579/// `database`, and `address` all match the incoming connection decides the
580/// outcome (`allow`/`reject`). If no rule matches, the connection is
581/// admitted (rules are an explicit deny/allow list, not default-deny — add a
582/// trailing `{ action = "reject", user = "all", database = "all", address =
583/// "all" }` for default-deny).
584#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct HbaRule {
586    /// "allow" or "reject".
587    pub action: HbaAction,
588    /// Matching PostgreSQL user, or "all".
589    #[serde(default = "hba_all")]
590    pub user: String,
591    /// Matching database, or "all".
592    #[serde(default = "hba_all")]
593    pub database: String,
594    /// Matching client address: "all", a bare IP, or a CIDR (e.g.
595    /// "10.0.0.0/8", "::1/128").
596    #[serde(default = "hba_all")]
597    pub address: String,
598}
599
600fn hba_all() -> String {
601    "all".to_string()
602}
603
604/// Admission action for an [`HbaRule`].
605#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
606#[serde(rename_all = "lowercase")]
607pub enum HbaAction {
608    Allow,
609    Reject,
610}
611
612fn default_write_timeout_secs() -> u64 {
613    30 // 30 seconds default write timeout during failover
614}
615
616/// A table exposed by the GraphQL gateway, with its selectable columns.
617#[derive(Debug, Clone, Default, Serialize, Deserialize)]
618#[serde(default)]
619pub struct GqlTableToml {
620    pub name: String,
621    pub columns: Vec<String>,
622}
623
624/// GraphQL-to-SQL gateway configuration. A separate HTTP listener; only active
625/// when the `graphql-gateway` feature is compiled in AND `enabled = true`.
626#[derive(Debug, Clone, Serialize, Deserialize)]
627#[serde(default)]
628pub struct GraphqlGatewayConfig {
629    /// Serve the GraphQL gateway. Default `false`.
630    pub enabled: bool,
631    /// HTTP listen address (e.g. `0.0.0.0:9091`).
632    pub listen_address: String,
633    /// Backend the generated SQL runs against.
634    pub backend_host: String,
635    pub backend_port: u16,
636    pub backend_user: String,
637    pub backend_password: Option<String>,
638    pub backend_database: Option<String>,
639    /// Optional Bearer token required on requests.
640    pub auth_token: Option<String>,
641    /// Tables exposed as GraphQL types.
642    pub tables: Vec<GqlTableToml>,
643}
644
645impl Default for GraphqlGatewayConfig {
646    fn default() -> Self {
647        Self {
648            enabled: false,
649            listen_address: "0.0.0.0:9091".to_string(),
650            backend_host: "127.0.0.1".to_string(),
651            backend_port: 5432,
652            backend_user: "postgres".to_string(),
653            backend_password: None,
654            backend_database: None,
655            auth_token: None,
656            tables: Vec::new(),
657        }
658    }
659}
660
661/// Schema/workload-aware routing configuration (always present). Only active
662/// when the `schema-routing` feature is compiled in AND `enabled = true`.
663#[derive(Debug, Clone, Default, Serialize, Deserialize)]
664#[serde(default)]
665pub struct SchemaRoutingToml {
666    /// Route analytical (OLAP) queries — aggregations, GROUP BY, window
667    /// functions — to a dedicated node. Default `false`.
668    pub enabled: bool,
669    /// Name of the node analytical queries are routed to.
670    pub analytics_node: String,
671}
672
673/// Multi-tenancy configuration (always present). Converted to a
674/// `multi_tenancy::TenantManager` at startup; only active when the
675/// `multi-tenancy` feature is compiled in AND `enabled = true`.
676#[derive(Debug, Clone, Serialize, Deserialize)]
677#[serde(default)]
678pub struct MultiTenancyToml {
679    /// Enforce per-tenant row isolation. Default `false`.
680    pub enabled: bool,
681    /// Which connection attribute names the tenant: a startup parameter name
682    /// (e.g. `application_name`, `user`) or the literal `database`.
683    pub identify_by: String,
684    /// The row-level tenant column injected into queries (e.g. `tenant_id`).
685    pub tenant_column: String,
686    /// Tables that are tenant-scoped (get the filter injected). Other tables
687    /// pass through unchanged.
688    pub tenant_tables: Vec<String>,
689    /// Known tenant ids.
690    pub tenants: Vec<String>,
691}
692
693impl Default for MultiTenancyToml {
694    fn default() -> Self {
695        Self {
696            enabled: false,
697            identify_by: "application_name".to_string(),
698            tenant_column: "tenant_id".to_string(),
699            tenant_tables: Vec::new(),
700            tenants: Vec::new(),
701        }
702    }
703}
704
705/// A single SQL-rewrite rule in TOML form. Maps to a `rewriter::RewriteRule`:
706/// `match_table`/`match_regex` choose which queries it applies to (default: all),
707/// and the first set transformation field is applied.
708#[derive(Debug, Clone, Serialize, Deserialize, Default)]
709#[serde(default)]
710pub struct RewriteRuleToml {
711    /// Apply to queries referencing this table.
712    pub match_table: Option<String>,
713    /// Apply to queries matching this regex.
714    pub match_regex: Option<String>,
715    /// Rewrite `match_table` -> this table name.
716    pub replace_table_with: Option<String>,
717    /// Append `AND <expr>` to the query's WHERE clause.
718    pub append_where: Option<String>,
719    /// Add a `LIMIT n` to an unbounded query.
720    pub add_limit: Option<u32>,
721}
722
723/// SQL query-rewriting configuration (always present). Converted to a
724/// `rewriter::QueryRewriter` at startup; only active when the `query-rewriting`
725/// feature is compiled in AND `enabled = true`.
726#[derive(Debug, Clone, Default, Serialize, Deserialize)]
727#[serde(default)]
728pub struct QueryRewriteToml {
729    /// Rewrite query SQL on the path per the rules below. Default `false`.
730    pub enabled: bool,
731    /// Ordered rewrite rules.
732    pub rules: Vec<RewriteRuleToml>,
733}
734
735/// Query-result cache configuration (TOML-friendly, always present). Converted
736/// to `crate::cache::CacheConfig` at startup and only active when the
737/// `query-cache` feature is compiled in AND `enabled = true`.
738#[derive(Debug, Clone, Serialize, Deserialize)]
739#[serde(default)]
740pub struct CacheToml {
741    /// Serve read SELECT results from an in-process L1/L2 cache. Default `false`.
742    pub enabled: bool,
743    /// Time-to-live for cached results, seconds.
744    pub ttl_secs: u64,
745    /// Maximum single result size to cache, bytes (larger results bypass).
746    pub max_result_bytes: usize,
747}
748
749impl Default for CacheToml {
750    fn default() -> Self {
751        Self {
752            enabled: false,
753            ttl_secs: 300,
754            max_result_bytes: 1024 * 1024,
755        }
756    }
757}
758
759// =============================================================================
760// OPERATIONAL LIMITS & TIMEOUTS (session/protocol safety bounds)
761// =============================================================================
762
763/// Operational safety limits and timeouts for the PG-wire data path. Every
764/// value here was previously a hardcoded `const` in `src/server.rs`; exposing
765/// them as a `[limits]` section makes each one tunable via `proxy.toml` without
766/// a recompile.
767///
768/// Defaults are byte-for-byte the prior compiled-in constants, so a config
769/// without a `[limits]` block (or one that omits any individual key) behaves
770/// exactly as before. Every timeout is expressed in whole seconds and every
771/// count/byte cap as a plain integer; all MUST be > 0. A `0` here would disable
772/// a safety bound rather than mean "unbounded" in any useful way, so
773/// [`ProxyConfig::validate`] rejects it.
774#[derive(Debug, Clone, Serialize, Deserialize)]
775pub struct LimitsToml {
776    /// Capacity of the query-cancellation key map (`BackendKeyData` → backend
777    /// address). At capacity the oldest entries are FIFO-evicted; a dropped
778    /// stale entry only means one best-effort `CancelRequest` is not forwarded.
779    /// Prior constant `MAX_CANCEL_KEYS`.
780    #[serde(default = "default_max_cancel_keys")]
781    pub max_cancel_keys: usize,
782    /// Deadline (seconds) for the pre-auth startup exchange (client TLS
783    /// negotiation + PostgreSQL startup/authentication). Bounds slow-loris
784    /// connections that stall mid-handshake; the query loop itself is not
785    /// bounded. Prior constant `STARTUP_TIMEOUT`.
786    #[serde(default = "default_startup_timeout_secs")]
787    pub startup_timeout_secs: u64,
788    /// Timeout (seconds) for a single backend write on the forward path — a
789    /// blackholed or hung backend must never pin a client task indefinitely.
790    /// Prior constant `BACKEND_WRITE_TIMEOUT`.
791    #[serde(default = "default_backend_write_timeout_secs")]
792    pub backend_write_timeout_secs: u64,
793    /// Timeout (seconds) for a single backend read on the relay path — a backend
794    /// that accepts the query but then emits no bytes must not pin a client task
795    /// forever. The paired counterpart to `backend_write_timeout_secs`; a
796    /// slow-but-healthy backend read (large sort / lock wait) is not itself
797    /// treated as a fault (see `is_backend_fault`).
798    #[serde(default = "default_backend_read_timeout_secs")]
799    pub backend_read_timeout_secs: u64,
800    /// Timeout (seconds) for a single client write — a wedged or very slow
801    /// client must not pin a proxy task (and the backend connection it holds)
802    /// forever. Prior constant `CLIENT_WRITE_TIMEOUT`.
803    #[serde(default = "default_client_write_timeout_secs")]
804    pub client_write_timeout_secs: u64,
805    /// Timeout (seconds) for the out-of-band re-prepare exchange (write
806    /// Parse+Flush, read ParseComplete) performed on a backend connection
807    /// switch. Prior constant `REPREPARE_TIMEOUT`.
808    #[serde(default = "default_reprepare_timeout_secs")]
809    pub reprepare_timeout_secs: u64,
810    /// Per-session cap on distinct named prepared statements — bounds the
811    /// per-session statement registry against a client issuing unbounded
812    /// `Parse`s. Prior constant `MAX_PREPARED_STATEMENTS`.
813    #[serde(default = "default_max_prepared_statements")]
814    pub max_prepared_statements: usize,
815    /// Per-session cap on the aggregate bytes retained in the statement
816    /// registry (each entry holds the full encoded `Parse`, so the count cap
817    /// alone does not bound memory). Prior constant `MAX_PREPARED_BYTES`.
818    #[serde(default = "default_max_prepared_bytes")]
819    pub max_prepared_bytes: usize,
820    /// Per-session cap on the un-flushed extended-protocol `pending` buffer: a
821    /// client must reach a Sync/Flush boundary before this many bytes pile up.
822    /// Prior constant `MAX_PENDING_BYTES`.
823    #[serde(default = "default_max_pending_bytes")]
824    pub max_pending_bytes: usize,
825    /// Global ceiling on idle connections parked in the data-path backend pool
826    /// across ALL `(node,user,db)` identities — bounds total file descriptors
827    /// regardless of how many distinct identities connect. Only consumed when
828    /// the `pool-modes` feature is compiled in; parsed-and-ignored otherwise.
829    /// Prior constant `MAX_TOTAL_IDLE_BACKEND_CONNS`.
830    #[serde(default = "default_max_total_idle_backend_conns")]
831    pub max_total_idle_backend_conns: usize,
832    /// How often (seconds) the idle-connection reaper runs. Prior constant
833    /// `POOL_REAP_INTERVAL`.
834    #[serde(default = "default_pool_reap_interval_secs")]
835    pub pool_reap_interval_secs: u64,
836}
837
838fn default_max_cancel_keys() -> usize {
839    100_000
840}
841fn default_startup_timeout_secs() -> u64 {
842    30
843}
844fn default_backend_write_timeout_secs() -> u64 {
845    30
846}
847fn default_backend_read_timeout_secs() -> u64 {
848    30
849}
850fn default_client_write_timeout_secs() -> u64 {
851    60
852}
853fn default_reprepare_timeout_secs() -> u64 {
854    15
855}
856fn default_max_prepared_statements() -> usize {
857    8192
858}
859fn default_max_prepared_bytes() -> usize {
860    64 * 1024 * 1024
861}
862fn default_max_pending_bytes() -> usize {
863    64 * 1024 * 1024
864}
865fn default_max_total_idle_backend_conns() -> usize {
866    8192
867}
868fn default_pool_reap_interval_secs() -> u64 {
869    30
870}
871
872/// Upper bound (seconds) for any `[limits]` `*_secs` timeout that feeds a
873/// `Duration`/`Instant`. Each of these is added to a `tokio::time::Instant` at
874/// connect time (`server.rs`); an enormous value such as `u64::MAX` overflows
875/// that `Instant + Duration` and panics the per-connection task. One year is
876/// far above any sane operational timeout while leaving enormous headroom below
877/// the overflow boundary, so [`ProxyConfig::validate`] rejects anything larger.
878const MAX_LIMIT_SECS: u64 = 31_536_000; // 1 year
879
880impl Default for LimitsToml {
881    fn default() -> Self {
882        Self {
883            max_cancel_keys: default_max_cancel_keys(),
884            startup_timeout_secs: default_startup_timeout_secs(),
885            backend_write_timeout_secs: default_backend_write_timeout_secs(),
886            backend_read_timeout_secs: default_backend_read_timeout_secs(),
887            client_write_timeout_secs: default_client_write_timeout_secs(),
888            reprepare_timeout_secs: default_reprepare_timeout_secs(),
889            max_prepared_statements: default_max_prepared_statements(),
890            max_prepared_bytes: default_max_prepared_bytes(),
891            max_pending_bytes: default_max_pending_bytes(),
892            max_total_idle_backend_conns: default_max_total_idle_backend_conns(),
893            pool_reap_interval_secs: default_pool_reap_interval_secs(),
894        }
895    }
896}
897
898/// Replica-lag-aware routing + read-your-writes configuration (always present;
899/// only enforced when the `lag-routing` feature is compiled in AND enabled).
900#[derive(Debug, Clone, Serialize, Deserialize)]
901#[serde(default)]
902pub struct LagRoutingToml {
903    /// Enable lag-aware read routing + read-your-writes. Default `false`.
904    pub enabled: bool,
905    /// Reads issued within this many milliseconds after a write in the same
906    /// session are pinned to the primary (read-your-writes), so the client
907    /// observes its own writes despite replica lag. 0 disables the window.
908    pub ryw_window_ms: u64,
909    /// Exclude a standby from read routing when its measured replication lag
910    /// exceeds this many bytes. 0 = no lag-based exclusion (default; the proxy
911    /// does not yet populate per-node lag without a configured monitor).
912    pub max_lag_bytes: u64,
913}
914
915impl Default for LagRoutingToml {
916    fn default() -> Self {
917        Self {
918            enabled: false,
919            ryw_window_ms: 500,
920            max_lag_bytes: 0,
921        }
922    }
923}
924
925/// Query-analytics configuration (TOML-friendly, always present). Converted to
926/// `crate::analytics::AnalyticsConfig` at startup and only active when the
927/// `query-analytics` feature is compiled in AND `enabled = true`.
928#[derive(Debug, Clone, Serialize, Deserialize)]
929#[serde(default)]
930pub struct AnalyticsToml {
931    /// Record per-query statistics, slow-query log, and pattern detection.
932    /// Default `false`.
933    pub enabled: bool,
934    /// Queries slower than this (milliseconds) are added to the slow-query log.
935    pub slow_query_ms: u64,
936    /// Maximum distinct query fingerprints to track.
937    pub max_fingerprints: u32,
938}
939
940impl Default for AnalyticsToml {
941    fn default() -> Self {
942        Self {
943            enabled: false,
944            slow_query_ms: 1000,
945            max_fingerprints: 10000,
946        }
947    }
948}
949
950/// Anomaly-detector configuration (TOML-friendly, always present so configs
951/// round-trip on any build). Converted to `crate::anomaly::AnomalyConfig` at
952/// startup and only consumed when the `anomaly-detection` feature is compiled
953/// in. Every default reproduces the detector's historical hardcoded
954/// `AnomalyConfig::default()` (and the old `MAX_SEEN_FINGERPRINTS` const)
955/// EXACTLY, so an absent `[anomaly]` section changes nothing.
956#[derive(Debug, Clone, Serialize, Deserialize)]
957pub struct AnomalyToml {
958    /// Rolling window for the per-tenant rate EWMA, seconds. Must be >= 1.
959    #[serde(default = "default_anomaly_rate_window_secs")]
960    pub rate_window_secs: u64,
961    /// Minimum z-score before a rate spike fires. Must be finite and > 0.
962    #[serde(default = "default_anomaly_spike_z_threshold")]
963    pub spike_z_threshold: f64,
964    /// Window for failed-auth (credential-stuffing) bursts, seconds. Must be
965    /// >= 1.
966    #[serde(default = "default_anomaly_auth_window_secs")]
967    pub auth_window_secs: u64,
968    /// Failures inside the auth window that escalate to Critical.
969    #[serde(default = "default_anomaly_auth_critical_count")]
970    pub auth_critical_count: u32,
971    /// Failures inside the auth window that escalate to Warning. Must be
972    /// <= `auth_critical_count`.
973    #[serde(default = "default_anomaly_auth_warning_count")]
974    pub auth_warning_count: u32,
975    /// Maximum events kept in the in-memory ring buffer. Must be >= 1.
976    #[serde(default = "default_anomaly_event_buffer_size")]
977    pub event_buffer_size: usize,
978    /// Emit novel-query fingerprints as informational events. Set `false` to
979    /// suppress on high-churn workloads (e.g. ad-hoc analytics).
980    #[serde(default = "default_anomaly_emit_novel_queries")]
981    pub emit_novel_queries: bool,
982    /// Upper bound on the novel-query fingerprint set before it is cleared
983    /// (bounds memory on high-cardinality SQL). Must be >= 1.
984    #[serde(default = "default_anomaly_max_seen_fingerprints")]
985    pub max_seen_fingerprints: usize,
986}
987
988fn default_anomaly_rate_window_secs() -> u64 {
989    60
990}
991fn default_anomaly_spike_z_threshold() -> f64 {
992    3.0
993}
994fn default_anomaly_auth_window_secs() -> u64 {
995    60
996}
997fn default_anomaly_auth_critical_count() -> u32 {
998    10
999}
1000fn default_anomaly_auth_warning_count() -> u32 {
1001    5
1002}
1003fn default_anomaly_event_buffer_size() -> usize {
1004    1024
1005}
1006fn default_anomaly_emit_novel_queries() -> bool {
1007    true
1008}
1009fn default_anomaly_max_seen_fingerprints() -> usize {
1010    100_000
1011}
1012
1013impl Default for AnomalyToml {
1014    fn default() -> Self {
1015        Self {
1016            rate_window_secs: default_anomaly_rate_window_secs(),
1017            spike_z_threshold: default_anomaly_spike_z_threshold(),
1018            auth_window_secs: default_anomaly_auth_window_secs(),
1019            auth_critical_count: default_anomaly_auth_critical_count(),
1020            auth_warning_count: default_anomaly_auth_warning_count(),
1021            event_buffer_size: default_anomaly_event_buffer_size(),
1022            emit_novel_queries: default_anomaly_emit_novel_queries(),
1023            max_seen_fingerprints: default_anomaly_max_seen_fingerprints(),
1024        }
1025    }
1026}
1027
1028#[cfg(feature = "anomaly-detection")]
1029impl AnomalyToml {
1030    /// Build the runtime detector config from the parsed `[anomaly]` section.
1031    /// Field-for-field; the defaults above guarantee this equals
1032    /// `crate::anomaly::AnomalyConfig::default()` when the section is absent.
1033    pub fn to_anomaly_config(&self) -> crate::anomaly::AnomalyConfig {
1034        crate::anomaly::AnomalyConfig {
1035            rate_window_secs: self.rate_window_secs,
1036            spike_z_threshold: self.spike_z_threshold,
1037            auth_window_secs: self.auth_window_secs,
1038            auth_critical_count: self.auth_critical_count,
1039            auth_warning_count: self.auth_warning_count,
1040            event_buffer_size: self.event_buffer_size,
1041            emit_novel_queries: self.emit_novel_queries,
1042            max_seen_fingerprints: self.max_seen_fingerprints,
1043        }
1044    }
1045}
1046
1047/// Circuit-breaker configuration (TOML-friendly, always present). Converted to
1048/// `crate::circuit_breaker::ManagerConfig` at startup and only enforced when
1049/// the `circuit-breaker` feature is compiled in AND `enabled = true`.
1050#[derive(Debug, Clone, Serialize, Deserialize)]
1051#[serde(default)]
1052pub struct CircuitBreakerToml {
1053    /// Trip backends out of rotation after repeated failures. Default `false`.
1054    pub enabled: bool,
1055    /// Consecutive failures (within the failure window) that open a node's
1056    /// circuit.
1057    pub failure_threshold: u32,
1058    /// How long a circuit stays open before a half-open probe is allowed.
1059    pub open_secs: u64,
1060    /// Successful probes required to close a half-open circuit.
1061    pub success_threshold: u32,
1062}
1063
1064impl Default for CircuitBreakerToml {
1065    fn default() -> Self {
1066        Self {
1067            enabled: false,
1068            failure_threshold: 5,
1069            open_secs: 10,
1070            success_threshold: 3,
1071        }
1072    }
1073}
1074
1075/// How rate-limit buckets are keyed.
1076#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1077#[serde(rename_all = "snake_case")]
1078pub enum RateLimitKeyBy {
1079    /// One bucket per authenticated user (startup `user` param).
1080    #[default]
1081    User,
1082    /// One bucket per client IP address.
1083    ClientIp,
1084    /// One bucket per target database.
1085    Database,
1086    /// A single global bucket for the whole proxy.
1087    Global,
1088}
1089
1090/// Rate-limiting configuration (TOML-friendly, always present so configs
1091/// round-trip on any build). Converted to `crate::rate_limit::RateLimitConfig`
1092/// at startup and only enforced when the `rate-limiting` feature is compiled
1093/// in AND `enabled = true`.
1094#[derive(Debug, Clone, Serialize, Deserialize)]
1095#[serde(default)]
1096pub struct RateLimitToml {
1097    /// Enforce rate limits. Default `false`.
1098    pub enabled: bool,
1099    /// Sustained queries per second per bucket.
1100    pub default_qps: u32,
1101    /// Burst capacity (token-bucket depth) per bucket.
1102    pub default_burst: u32,
1103    /// Max concurrent in-flight queries per bucket (0 = use the engine default).
1104    pub max_concurrent: u32,
1105    /// What each bucket is keyed on.
1106    pub key_by: RateLimitKeyBy,
1107}
1108
1109impl Default for RateLimitToml {
1110    fn default() -> Self {
1111        Self {
1112            enabled: false,
1113            default_qps: 1000,
1114            default_burst: 2000,
1115            max_concurrent: 0,
1116            key_by: RateLimitKeyBy::User,
1117        }
1118    }
1119}
1120
1121/// SQL-comment routing-hint configuration.
1122///
1123/// Always present on `ProxyConfig` so configs round-trip on any build, but the
1124/// hints are only parsed and honored when the `routing-hints` feature is
1125/// compiled in AND `enabled = true`.
1126#[derive(Debug, Clone, Serialize, Deserialize)]
1127#[serde(default)]
1128pub struct RoutingHintsConfig {
1129    /// Parse and honor `/*helios:...*/` routing hints. Default `false`
1130    /// (preserves the pure verb-based routing behaviour).
1131    pub enabled: bool,
1132    /// Strip the hint comment from the SQL before forwarding to the backend.
1133    /// Default `true`. Hint comments are valid SQL comments, so leaving them
1134    /// in is harmless; stripping keeps backend query logs clean.
1135    pub strip_hints: bool,
1136}
1137
1138impl Default for RoutingHintsConfig {
1139    fn default() -> Self {
1140        Self {
1141            enabled: false,
1142            strip_hints: true,
1143        }
1144    }
1145}
1146
1147impl Default for ProxyConfig {
1148    fn default() -> Self {
1149        Self {
1150            listen_address: "0.0.0.0:5432".to_string(),
1151            // Loopback by default: the admin API is privileged and must not be
1152            // exposed to the network unless the operator opts in (with a token,
1153            // or admin_allow_insecure). A fresh install is safe.
1154            admin_address: "127.0.0.1:9090".to_string(),
1155            admin_token: None,
1156            admin_allow_insecure: false,
1157            tr_enabled: true,
1158            tr_mode: TrMode::Session,
1159            pool: PoolConfig::default(),
1160            pool_mode: PoolModeConfig::default(),
1161            load_balancer: LoadBalancerConfig::default(),
1162            health: HealthConfig::default(),
1163            nodes: Vec::new(),
1164            tls: None,
1165            write_timeout_secs: default_write_timeout_secs(),
1166            plugins: PluginToml::default(),
1167            hba: Vec::new(),
1168            auth: AuthConfig::default(),
1169            mcp: McpConfig::default(),
1170            agent_contracts: Vec::new(),
1171            http_gateway: HttpGatewayConfig::default(),
1172            mirror: MirrorConfig::default(),
1173            edge: crate::edge::EdgeConfig::default(),
1174            branch: BranchConfig::default(),
1175            routing_hints: RoutingHintsConfig::default(),
1176            rate_limit: RateLimitToml::default(),
1177            circuit_breaker: CircuitBreakerToml::default(),
1178            analytics: AnalyticsToml::default(),
1179            anomaly: AnomalyToml::default(),
1180            lag_routing: LagRoutingToml::default(),
1181            cache: CacheToml::default(),
1182            query_rewrite: QueryRewriteToml::default(),
1183            multi_tenancy: MultiTenancyToml::default(),
1184            schema_routing: SchemaRoutingToml::default(),
1185            graphql_gateway: GraphqlGatewayConfig::default(),
1186            optimize_unnamed_parse: true,
1187            shutdown_drain_timeout_secs: default_drain_timeout_secs(),
1188            limits: LimitsToml::default(),
1189        }
1190    }
1191}
1192
1193// =============================================================================
1194// PLUGIN SYSTEM CONFIG (TOML-friendly shape)
1195// =============================================================================
1196
1197/// Plugin-system configuration, in a TOML-friendly shape.
1198///
1199/// Always present on `ProxyConfig` so existing configs round-trip, but only
1200/// consumed when the `wasm-plugins` feature is enabled. When
1201/// `plugins.enabled` is `false` (the default), plugin loading is skipped
1202/// entirely and every plugin-hook call site becomes a zero-cost no-op.
1203///
1204/// Converted to `crate::plugins::PluginRuntimeConfig` at startup via a
1205/// feature-gated `From` impl in `src/plugins/config.rs`.
1206#[derive(Debug, Clone, Serialize, Deserialize)]
1207pub struct PluginToml {
1208    /// Enable the plugin subsystem. Defaults to `false` — plugins are
1209    /// strictly opt-in.
1210    #[serde(default)]
1211    pub enabled: bool,
1212    /// Directory to scan at startup for `.wasm` plugin files.
1213    #[serde(default = "default_plugin_dir")]
1214    pub plugin_dir: String,
1215    /// Watch `plugin_dir` for file changes and reload plugins hot.
1216    #[serde(default)]
1217    pub hot_reload: bool,
1218    /// Memory limit per plugin instance, in megabytes.
1219    #[serde(default = "default_plugin_memory_mb")]
1220    pub memory_limit_mb: usize,
1221    /// Execution timeout per hook call, in milliseconds.
1222    #[serde(default = "default_plugin_timeout_ms")]
1223    pub timeout_ms: u64,
1224    /// Maximum number of concurrently-loaded plugins.
1225    #[serde(default = "default_plugin_max")]
1226    pub max_plugins: usize,
1227    /// Enable per-call CPU-cycle (fuel) metering to bound plugin runtime.
1228    #[serde(default = "default_true")]
1229    pub fuel_metering: bool,
1230    /// Fuel units allowed per hook call when `fuel_metering = true`.
1231    #[serde(default = "default_plugin_fuel")]
1232    pub fuel_limit: u64,
1233    /// Optional Ed25519 trust-root directory. When set, every loaded
1234    /// .wasm requires a sidecar .sig that verifies against one of
1235    /// the *.pub files in this directory. When omitted, signatures
1236    /// are not checked (preserves the dev-loop ergonomic of dropping
1237    /// unsigned .wasm files in the plugin dir).
1238    #[serde(default)]
1239    pub trust_root: Option<String>,
1240    /// Max bytes for a single plugin-KV value (via `kv_set` or the
1241    /// `PUT /admin/kv/<plugin>/<key>` endpoint). `0` = unlimited.
1242    #[serde(default = "default_plugin_kv_max_value_bytes")]
1243    pub kv_max_value_bytes: usize,
1244    /// Max distinct keys per plugin KV namespace. `0` = unlimited.
1245    #[serde(default = "default_plugin_kv_max_keys")]
1246    pub kv_max_keys_per_plugin: usize,
1247    /// Max distinct plugin KV namespaces that may exist at once. Bounds
1248    /// how many `<plugin>` namespaces the `PUT /admin/kv/<plugin>/<key>`
1249    /// endpoint can bring into existence, so a token-holding caller
1250    /// cannot exhaust memory by writing to unboundedly-many namespace
1251    /// names. `0` = unlimited.
1252    #[serde(default = "default_plugin_kv_max_plugins")]
1253    pub kv_max_plugins: usize,
1254    /// Max TOTAL retained bytes across ALL plugin KV namespaces (each
1255    /// entry's key + value bytes plus each live namespace's name
1256    /// bytes). `0` = unlimited. This is the survivable-default backstop:
1257    /// the per-axis product `kv_max_plugins × kv_max_keys_per_plugin ×
1258    /// kv_max_value_bytes` can reach tens of GiB, so this single cap
1259    /// bounds actual retained memory to a tunable ceiling and prevents
1260    /// a token-holding `PUT /admin/kv` caller from driving the proxy to
1261    /// an OOM. Default **67108864** (64 MiB).
1262    #[serde(default = "default_plugin_kv_max_total_bytes")]
1263    pub kv_max_total_bytes: usize,
1264}
1265
1266fn default_plugin_dir() -> String {
1267    "/etc/heliosproxy/plugins".to_string()
1268}
1269fn default_plugin_memory_mb() -> usize {
1270    64
1271}
1272fn default_plugin_timeout_ms() -> u64 {
1273    100
1274}
1275fn default_plugin_max() -> usize {
1276    20
1277}
1278fn default_true() -> bool {
1279    true
1280}
1281fn default_plugin_fuel() -> u64 {
1282    1_000_000
1283}
1284fn default_plugin_kv_max_value_bytes() -> usize {
1285    65536
1286}
1287fn default_plugin_kv_max_keys() -> usize {
1288    1024
1289}
1290fn default_plugin_kv_max_plugins() -> usize {
1291    256
1292}
1293fn default_plugin_kv_max_total_bytes() -> usize {
1294    64 * 1024 * 1024
1295}
1296
1297impl Default for PluginToml {
1298    fn default() -> Self {
1299        Self {
1300            enabled: false,
1301            plugin_dir: default_plugin_dir(),
1302            hot_reload: false,
1303            memory_limit_mb: default_plugin_memory_mb(),
1304            timeout_ms: default_plugin_timeout_ms(),
1305            max_plugins: default_plugin_max(),
1306            fuel_metering: true,
1307            fuel_limit: default_plugin_fuel(),
1308            trust_root: None,
1309            kv_max_value_bytes: default_plugin_kv_max_value_bytes(),
1310            kv_max_keys_per_plugin: default_plugin_kv_max_keys(),
1311            kv_max_plugins: default_plugin_kv_max_plugins(),
1312            kv_max_total_bytes: default_plugin_kv_max_total_bytes(),
1313        }
1314    }
1315}
1316
1317// =============================================================================
1318// ENV-VAR SUBSTITUTION + UNKNOWN-KEY DETECTION (config-loader helpers)
1319// =============================================================================
1320
1321/// Expand `${NAME}` and `${NAME:-default}` environment-variable references in a
1322/// raw config file, in place, BEFORE it is parsed as TOML.
1323///
1324/// * `${NAME}`          → the value of env var `NAME`; returns an `Err` naming
1325///   `NAME` if it is unset (fail-fast, 12-factor — the literal is never left
1326///   in the output).
1327/// * `${NAME:-default}` → env var `NAME` if set, otherwise the literal
1328///   `default` (which may be empty; the default text runs up to the first `}`).
1329///
1330/// `NAME` must match `[A-Za-z_][A-Za-z0-9_]*`. Substitution is IN PLACE, so an
1331/// unquoted `${POOL_MAX:-50}` becomes the bare token `50` (valid TOML) and a
1332/// quoted `"${X:-y}"` becomes `"y"`.
1333///
1334/// SECURITY: this is plain string substitution. It performs ONLY an env lookup
1335/// plus the `:-` default operator — it never spawns a shell or evaluates
1336/// anything. Trust boundary: substitution is textual and runs BEFORE the TOML
1337/// parse, so a substituted value (env var or `:-default`) that contains a quote
1338/// or newline can inject arbitrary TOML structure. Env vars and the config file
1339/// are operator-controlled, so this is acceptable; do NOT feed an
1340/// untrusted-party-controlled env var into a `${...}` reference.
1341///
1342/// Comment-awareness is intentionally LINE-LEVEL only: a line whose first
1343/// non-whitespace byte is `#` (a full-line TOML comment) is copied verbatim so
1344/// the many commented `${VAR}` examples in the shipped reference configs (some
1345/// with no `:-default`) do not trigger the unset-variable error. A trailing
1346/// `#` comment on a value line is NOT treated as a comment — a full
1347/// TOML-comment-aware tokenizer is overkill for the shipped configs and out of
1348/// scope here.
1349fn substitute_env(text: &str) -> Result<String> {
1350    // `split_inclusive` keeps the line terminator attached to each piece, so
1351    // reassembling preserves the original text (including a missing trailing
1352    // newline) exactly outside of the substituted spans.
1353    let mut out = String::with_capacity(text.len());
1354    for line in text.split_inclusive('\n') {
1355        if line.trim_start().starts_with('#') {
1356            // Full-line comment: copy verbatim, never substitute.
1357            out.push_str(line);
1358        } else {
1359            substitute_line(line, &mut out)?;
1360        }
1361    }
1362    Ok(out)
1363}
1364
1365/// Expand every `${...}` reference in a single (non-comment) line into `out`.
1366fn substitute_line(line: &str, out: &mut String) -> Result<()> {
1367    let mut rest = line;
1368    while let Some(idx) = rest.find("${") {
1369        out.push_str(&rest[..idx]);
1370        let body = &rest[idx + 2..]; // text after the opening "${"
1371        match parse_placeholder(body)? {
1372            Some((value, consumed)) => {
1373                out.push_str(&value);
1374                rest = &body[consumed..];
1375            }
1376            None => {
1377                // Not a well-formed `${NAME...}`: emit the literal "${" and
1378                // keep scanning after it so a later valid placeholder on the
1379                // same line still expands.
1380                out.push_str("${");
1381                rest = body;
1382            }
1383        }
1384    }
1385    out.push_str(rest);
1386    Ok(())
1387}
1388
1389/// Parse the body of a placeholder (the text AFTER the opening `${`).
1390///
1391/// On success returns `(replacement, consumed)` where `consumed` is the number
1392/// of bytes of `body` consumed INCLUDING the closing `}`. Returns `Ok(None)`
1393/// when `body` is not a well-formed placeholder (the caller leaves it literal).
1394/// Returns `Err` only for a well-formed `${NAME}` (no default) whose env var is
1395/// unset.
1396fn parse_placeholder(body: &str) -> Result<Option<(String, usize)>> {
1397    let bytes = body.as_bytes();
1398    // NAME = [A-Za-z_][A-Za-z0-9_]*
1399    let mut n = 0;
1400    while n < bytes.len() {
1401        let b = bytes[n];
1402        let valid = if n == 0 {
1403            b.is_ascii_alphabetic() || b == b'_'
1404        } else {
1405            b.is_ascii_alphanumeric() || b == b'_'
1406        };
1407        if valid {
1408            n += 1;
1409        } else {
1410            break;
1411        }
1412    }
1413    if n == 0 {
1414        return Ok(None); // no valid NAME after `${`
1415    }
1416    let name = &body[..n];
1417    let after = &body[n..];
1418
1419    if after.starts_with('}') {
1420        // `${NAME}` — must be set, no fallback.
1421        match std::env::var(name) {
1422            Ok(v) => Ok(Some((v, n + 1))),
1423            Err(_) => Err(ProxyError::Config(format!(
1424                "config env-var substitution: `${{{name}}}` references environment \
1425                 variable `{name}`, which is not set (and no `:-default` fallback \
1426                 was given)"
1427            ))),
1428        }
1429    } else if let Some(after_op) = after.strip_prefix(":-") {
1430        // `${NAME:-default}` — default runs up to the first `}`.
1431        match after_op.find('}') {
1432            Some(end) => {
1433                let default = &after_op[..end];
1434                let value = std::env::var(name).unwrap_or_else(|_| default.to_string());
1435                // consumed = NAME(n) + ":-"(2) + default(end) + "}"(1)
1436                Ok(Some((value, n + 2 + end + 1)))
1437            }
1438            None => Ok(None), // unterminated placeholder: leave literal
1439        }
1440    } else {
1441        Ok(None) // NAME followed by an unexpected char: leave literal
1442    }
1443}
1444
1445/// Top-level keys recognised by [`ProxyConfig`]. Keep in sync with the struct's
1446/// fields (there are no field-level `#[serde(rename)]`s, so these are the exact
1447/// Rust field names). Used ONLY to warn on unknown top-level sections/keys;
1448/// deserialization itself still silently ignores unknowns (there is no
1449/// `deny_unknown_fields`), so a stale entry here only mutes or duplicates a
1450/// warning — it can never reject a config. The
1451/// `test_known_top_level_keys_cover_struct_fields` drift guard fails CI if a
1452/// new non-optional field is added without updating this list.
1453const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
1454    "listen_address",
1455    "admin_address",
1456    "admin_token",
1457    "admin_allow_insecure",
1458    "tr_enabled",
1459    "tr_mode",
1460    "pool",
1461    "pool_mode",
1462    "load_balancer",
1463    "health",
1464    "nodes",
1465    "tls",
1466    "write_timeout_secs",
1467    "plugins",
1468    "hba",
1469    "auth",
1470    "mcp",
1471    "agent_contracts",
1472    "http_gateway",
1473    "mirror",
1474    "edge",
1475    "branch",
1476    "routing_hints",
1477    "rate_limit",
1478    "circuit_breaker",
1479    "analytics",
1480    "anomaly",
1481    "lag_routing",
1482    "cache",
1483    "query_rewrite",
1484    "multi_tenancy",
1485    "schema_routing",
1486    "graphql_gateway",
1487    "optimize_unnamed_parse",
1488    "shutdown_drain_timeout_secs",
1489    "limits",
1490];
1491
1492/// Detect TOP-LEVEL TOML keys that are not fields of [`ProxyConfig`] (silent
1493/// doc drift / typos). Pure and testable: parses `text` as a TOML table and
1494/// diffs its top-level keys against [`KNOWN_TOP_LEVEL_KEYS`], returning the
1495/// unknowns sorted. Nested unknown keys (e.g. `[cache.l1]`) are intentionally
1496/// OUT OF SCOPE. If `text` does not parse as a TOML table this returns empty —
1497/// the caller's `toml::from_str` already reports genuine parse errors.
1498fn unknown_top_level_keys(text: &str) -> Vec<String> {
1499    let Ok(value) = toml::from_str::<toml::Value>(text) else {
1500        return Vec::new();
1501    };
1502    let Some(table) = value.as_table() else {
1503        return Vec::new();
1504    };
1505    let mut unknown: Vec<String> = table
1506        .keys()
1507        .filter(|k| !KNOWN_TOP_LEVEL_KEYS.contains(&k.as_str()))
1508        .cloned()
1509        .collect();
1510    unknown.sort();
1511    unknown
1512}
1513
1514impl ProxyConfig {
1515    /// Get write timeout as Duration
1516    pub fn write_timeout(&self) -> Duration {
1517        Duration::from_secs(self.write_timeout_secs)
1518    }
1519
1520    /// Load configuration from file
1521    pub fn from_file(path: &str) -> Result<Self> {
1522        let path = Path::new(path);
1523
1524        if !path.exists() {
1525            return Err(ProxyError::Config(format!(
1526                "Configuration file not found: {}",
1527                path.display()
1528            )));
1529        }
1530
1531        let raw = std::fs::read_to_string(path)
1532            .map_err(|e| ProxyError::Config(format!("Failed to read config: {}", e)))?;
1533
1534        // Expand `${VAR}` / `${VAR:-default}` references before parsing — the
1535        // 12-factor substitution the shipped example configs advertise and use.
1536        // Fails fast (naming the variable) if a bare `${VAR}` has no value and
1537        // no `:-default` fallback.
1538        let contents = substitute_env(&raw)?;
1539
1540        let config: Self = toml::from_str(&contents)
1541            .map_err(|e| ProxyError::Config(format!("Failed to parse config: {}", e)))?;
1542
1543        // Surface unknown TOP-LEVEL sections/keys (e.g. documented-but-
1544        // unimplemented `[ha]`/`[logging]`/`[metrics]` blocks) as warnings so
1545        // silent doc drift becomes visible. We deliberately do NOT reject them:
1546        // there is no `deny_unknown_fields`, so deserialization already ignores
1547        // unknown fields and pre-existing configs with such sections keep
1548        // loading. Nested unknown keys are out of scope.
1549        for key in unknown_top_level_keys(&contents) {
1550            tracing::warn!(
1551                "unknown config section/key '{}' ignored (not part of ProxyConfig)",
1552                key
1553            );
1554        }
1555
1556        config.validate()?;
1557
1558        Ok(config)
1559    }
1560
1561    /// Add a node from host:port string
1562    pub fn add_node(&mut self, host_port: &str, role: &str) -> Result<()> {
1563        let parts: Vec<&str> = host_port.rsplitn(2, ':').collect();
1564        if parts.len() != 2 {
1565            return Err(ProxyError::Config(format!(
1566                "Invalid host:port format: {}",
1567                host_port
1568            )));
1569        }
1570
1571        let port: u16 = parts[0]
1572            .parse()
1573            .map_err(|_| ProxyError::Config(format!("Invalid port: {}", parts[0])))?;
1574
1575        let host = parts[1].to_string();
1576
1577        let role = match role {
1578            "primary" => NodeRole::Primary,
1579            "standby" => NodeRole::Standby,
1580            "replica" => NodeRole::ReadReplica,
1581            _ => return Err(ProxyError::Config(format!("Unknown role: {}", role))),
1582        };
1583
1584        self.nodes.push(NodeConfig {
1585            host,
1586            port,
1587            http_port: default_http_port(),
1588            role,
1589            weight: 100,
1590            enabled: true,
1591            name: None,
1592        });
1593
1594        Ok(())
1595    }
1596
1597    /// Validate configuration
1598    pub fn validate(&self) -> Result<()> {
1599        // Must have at least one node
1600        if self.nodes.is_empty() {
1601            return Err(ProxyError::Config(
1602                "No backend nodes configured".to_string(),
1603            ));
1604        }
1605
1606        // Must have a primary node
1607        let has_primary = self.nodes.iter().any(|n| n.role == NodeRole::Primary);
1608        if !has_primary {
1609            return Err(ProxyError::Config("No primary node configured".to_string()));
1610        }
1611
1612        // Validate pool config
1613        if self.pool.max_connections < self.pool.min_connections {
1614            return Err(ProxyError::Config(
1615                "max_connections must be >= min_connections".to_string(),
1616            ));
1617        }
1618
1619        // A zero health-check interval panics `tokio::time::interval` at
1620        // construction, silently killing the health task (which then never
1621        // probes, so the proxy keeps routing to dead backends). Reject it here;
1622        // the checker also clamps to a 1s floor as belt-and-suspenders.
1623        if self.health.check_interval_secs == 0 {
1624            return Err(ProxyError::Config(
1625                "health.check_interval_secs must be >= 1".to_string(),
1626            ));
1627        }
1628
1629        // Refuse to expose the admin API beyond loopback without a token. The
1630        // admin surface runs privileged operations (arbitrary SQL via
1631        // /api/sql, forced failover via /api/chaos, migration cutover, branch
1632        // CREATE/DROP DATABASE, replay/shadow against operator-chosen targets),
1633        // so an anonymous non-loopback bind is a critical hole. Only enforced
1634        // when the address parses to a concrete IP; a hostname is left to the
1635        // operator's DNS/network policy.
1636        if self.admin_token.is_none() && !self.admin_allow_insecure {
1637            if let Ok(sa) = self.admin_address.parse::<std::net::SocketAddr>() {
1638                if !sa.ip().is_loopback() {
1639                    return Err(ProxyError::Config(format!(
1640                        "admin_address '{}' is not loopback but admin_token is unset — the admin \
1641                         API runs privileged operations and must not be exposed anonymously. Set \
1642                         admin_token, bind admin_address to 127.0.0.1, or set \
1643                         admin_allow_insecure = true to override.",
1644                        self.admin_address
1645                    )));
1646                }
1647            }
1648        }
1649
1650        // Operational limits/timeouts. Each of these was a compiled-in safety
1651        // bound; a 0 disables the bound (slow-loris handshake never times out,
1652        // an unbounded statement registry, a zero-capacity map, a reaper that
1653        // panics `tokio::time::interval`) rather than meaning anything useful,
1654        // so reject 0 up front with a message that names the key.
1655        {
1656            let l = &self.limits;
1657            let zero_checks: [(&str, u64); 7] = [
1658                ("limits.startup_timeout_secs", l.startup_timeout_secs),
1659                (
1660                    "limits.backend_write_timeout_secs",
1661                    l.backend_write_timeout_secs,
1662                ),
1663                (
1664                    "limits.backend_read_timeout_secs",
1665                    l.backend_read_timeout_secs,
1666                ),
1667                (
1668                    "limits.client_write_timeout_secs",
1669                    l.client_write_timeout_secs,
1670                ),
1671                ("limits.reprepare_timeout_secs", l.reprepare_timeout_secs),
1672                ("limits.pool_reap_interval_secs", l.pool_reap_interval_secs),
1673                ("limits.max_cancel_keys", l.max_cancel_keys as u64),
1674            ];
1675            for (name, value) in zero_checks {
1676                if value == 0 {
1677                    return Err(ProxyError::Config(format!("{name} must be >= 1")));
1678                }
1679            }
1680            // Upper-bound every `*_secs` timeout that feeds a `Duration`/`Instant`.
1681            // A value beyond MAX_LIMIT_SECS (e.g. `u64::MAX`) overflows the
1682            // `Instant + Duration` computed at connect time in `server.rs` and
1683            // panics the per-connection task, so reject it up front.
1684            let secs_checks: [(&str, u64); 6] = [
1685                ("limits.startup_timeout_secs", l.startup_timeout_secs),
1686                (
1687                    "limits.backend_write_timeout_secs",
1688                    l.backend_write_timeout_secs,
1689                ),
1690                (
1691                    "limits.backend_read_timeout_secs",
1692                    l.backend_read_timeout_secs,
1693                ),
1694                (
1695                    "limits.client_write_timeout_secs",
1696                    l.client_write_timeout_secs,
1697                ),
1698                ("limits.reprepare_timeout_secs", l.reprepare_timeout_secs),
1699                ("limits.pool_reap_interval_secs", l.pool_reap_interval_secs),
1700            ];
1701            for (name, value) in secs_checks {
1702                if value > MAX_LIMIT_SECS {
1703                    return Err(ProxyError::Config(format!(
1704                        "{name} must be <= {MAX_LIMIT_SECS} seconds (1 year)"
1705                    )));
1706                }
1707            }
1708            if l.max_prepared_statements == 0 {
1709                return Err(ProxyError::Config(
1710                    "limits.max_prepared_statements must be >= 1".to_string(),
1711                ));
1712            }
1713            if l.max_prepared_bytes == 0 {
1714                return Err(ProxyError::Config(
1715                    "limits.max_prepared_bytes must be >= 1".to_string(),
1716                ));
1717            }
1718            if l.max_pending_bytes == 0 {
1719                return Err(ProxyError::Config(
1720                    "limits.max_pending_bytes must be >= 1".to_string(),
1721                ));
1722            }
1723            if l.max_total_idle_backend_conns == 0 {
1724                return Err(ProxyError::Config(
1725                    "limits.max_total_idle_backend_conns must be >= 1".to_string(),
1726                ));
1727            }
1728        }
1729
1730        // Edge / geo proxy mode. The [edge] section is parsed on every build
1731        // (so configs round-trip), but enabling it needs the compile-time
1732        // feature, and an edge role needs both its control plane (the home's
1733        // admin URL for the invalidation subscription) and its data plane
1734        // (at least one [[nodes]] entry pointing at the home's PG-wire
1735        // listener, through which misses and writes forward).
1736        if self.edge.enabled {
1737            if !cfg!(feature = "edge-proxy") {
1738                return Err(ProxyError::Config(
1739                    "edge.enabled = true but this binary was built without the 'edge-proxy' \
1740                     feature — rebuild with `--features edge-proxy` or remove/disable the \
1741                     [edge] section."
1742                        .to_string(),
1743                ));
1744            }
1745            // Same rationale as health.check_interval_secs: a zero interval
1746            // panics `tokio::time::interval` at construction, silently killing
1747            // the registry GC task. And a zero liveness window would prune
1748            // every edge on the first sweep. Reject both up front.
1749            if self.edge.subscribe_gc_secs == 0 {
1750                return Err(ProxyError::Config(
1751                    "edge.subscribe_gc_secs must be >= 1".to_string(),
1752                ));
1753            }
1754            if self.edge.liveness_window_secs == 0 {
1755                return Err(ProxyError::Config(
1756                    "edge.liveness_window_secs must be >= 1".to_string(),
1757                ));
1758            }
1759            // A zero TTL births every entry expired — the cache would
1760            // silently never serve a hit while looking enabled.
1761            if self.edge.default_ttl_secs == 0 {
1762                return Err(ProxyError::Config(
1763                    "edge.default_ttl_secs must be >= 1 when edge is enabled".to_string(),
1764                ));
1765            }
1766            if self.edge.role == crate::edge::EdgeRole::Edge {
1767                if self.edge.home_url.trim().is_empty() {
1768                    return Err(ProxyError::Config(
1769                        "edge.role = 'edge' requires edge.home_url — the home proxy's admin \
1770                         base URL (e.g. \"https://home-proxy:9090\") the edge subscribes to \
1771                         for cache invalidations."
1772                            .to_string(),
1773                    ));
1774                }
1775                // The auth_token is the home's ADMIN bearer (arbitrary SQL,
1776                // chaos, replay). Never transmit it in cleartext across the
1777                // edge<->home WAN link: require https, or an explicit opt-out
1778                // for provably private links (mirrors admin_allow_insecure).
1779                // URL schemes are case-insensitive (RFC 3986); compare lowered
1780                // so a legitimate `HTTPS://` is not wrongly rejected here (nor
1781                // left without downgrade protection in the client).
1782                if !self.edge.auth_token.is_empty()
1783                    && !self.edge.allow_insecure_home_url
1784                    && !self
1785                        .edge
1786                        .home_url
1787                        .trim()
1788                        .to_ascii_lowercase()
1789                        .starts_with("https://")
1790                {
1791                    return Err(ProxyError::Config(format!(
1792                        "edge.home_url '{}' is not https:// but edge.auth_token is set — the \
1793                         token is the home's admin bearer and must not cross the network in \
1794                         cleartext. Front the home admin port with a TLS terminator and use \
1795                         https://, or set edge.allow_insecure_home_url = true for private \
1796                         links (VPN/WireGuard/service mesh).",
1797                        self.edge.home_url.trim()
1798                    )));
1799                }
1800                // The query-result cache is not wired to SSE invalidations:
1801                // on an edge it would keep serving rows the home already
1802                // invalidated, for the full query-cache TTL. Refuse the
1803                // combination (the edge cache covers cacheable SELECTs here).
1804                if cfg!(feature = "query-cache") && self.cache.enabled {
1805                    return Err(ProxyError::Config(
1806                        "edge.role = 'edge' cannot be combined with [cache] enabled = true — \
1807                         the query-result cache does not receive edge invalidations and would \
1808                         serve stale rows past the edge coherence bound. Disable [cache] on \
1809                         edge-role proxies; the edge cache serves cacheable SELECTs there."
1810                            .to_string(),
1811                    ));
1812                }
1813                // Redundant with the global no-nodes check above today, but
1814                // kept for a message that says *what the node is for* in
1815                // edge mode should that check ever be relaxed.
1816                if self.nodes.is_empty() {
1817                    return Err(ProxyError::Config(
1818                        "edge.role = 'edge' requires at least one [[nodes]] entry pointing \
1819                         at the home proxy's PG-wire listener — cache misses and writes \
1820                         forward there."
1821                            .to_string(),
1822                    ));
1823                }
1824            }
1825        }
1826
1827        // Anomaly detector tunables. Parsed on every build; only consumed when
1828        // the `anomaly-detection` feature is compiled in, but the values are
1829        // degenerate regardless of feature, so validate them unconditionally.
1830        // A zero rate/auth window would divide by an empty sliding window; a
1831        // zero event buffer or fingerprint cap would break the ring buffer /
1832        // novel-query set; a non-positive or non-finite z-threshold would never
1833        // (or always) fire; a warning count above the critical count inverts
1834        // the severity ladder.
1835        {
1836            let a = &self.anomaly;
1837            if a.rate_window_secs == 0 {
1838                return Err(ProxyError::Config(
1839                    "anomaly.rate_window_secs must be >= 1".to_string(),
1840                ));
1841            }
1842            if a.auth_window_secs == 0 {
1843                return Err(ProxyError::Config(
1844                    "anomaly.auth_window_secs must be >= 1".to_string(),
1845                ));
1846            }
1847            if a.event_buffer_size == 0 {
1848                return Err(ProxyError::Config(
1849                    "anomaly.event_buffer_size must be >= 1".to_string(),
1850                ));
1851            }
1852            if a.max_seen_fingerprints == 0 {
1853                return Err(ProxyError::Config(
1854                    "anomaly.max_seen_fingerprints must be >= 1".to_string(),
1855                ));
1856            }
1857            if !(a.spike_z_threshold.is_finite() && a.spike_z_threshold > 0.0) {
1858                return Err(ProxyError::Config(
1859                    "anomaly.spike_z_threshold must be a finite value > 0".to_string(),
1860                ));
1861            }
1862            if a.auth_critical_count == 0 {
1863                // A 0 critical threshold fires Critical on the very first failed
1864                // auth (count starts at 1 >= 0), turning every login typo into a
1865                // critical alert. Require at least 1.
1866                return Err(ProxyError::Config(
1867                    "anomaly.auth_critical_count must be >= 1".to_string(),
1868                ));
1869            }
1870            if a.auth_warning_count > a.auth_critical_count {
1871                return Err(ProxyError::Config(format!(
1872                    "anomaly.auth_warning_count ({}) must be <= anomaly.auth_critical_count ({})",
1873                    a.auth_warning_count, a.auth_critical_count
1874                )));
1875            }
1876        }
1877
1878        Ok(())
1879    }
1880
1881    /// Get primary node
1882    pub fn primary_node(&self) -> Option<&NodeConfig> {
1883        self.nodes
1884            .iter()
1885            .find(|n| n.role == NodeRole::Primary && n.enabled)
1886    }
1887
1888    /// Get standby nodes
1889    pub fn standby_nodes(&self) -> Vec<&NodeConfig> {
1890        self.nodes
1891            .iter()
1892            .filter(|n| n.role == NodeRole::Standby && n.enabled)
1893            .collect()
1894    }
1895
1896    /// Get all enabled nodes
1897    pub fn enabled_nodes(&self) -> Vec<&NodeConfig> {
1898        self.nodes.iter().filter(|n| n.enabled).collect()
1899    }
1900}
1901
1902/// TR (Transaction Replay) mode
1903#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1904#[serde(rename_all = "lowercase")]
1905#[derive(Default)]
1906pub enum TrMode {
1907    /// No transaction replay
1908    None,
1909    /// Re-establish session only
1910    #[default]
1911    Session,
1912    /// Re-execute SELECT queries
1913    Select,
1914    /// Full transaction replay
1915    Transaction,
1916}
1917
1918/// Connection pool configuration
1919#[derive(Debug, Clone, Serialize, Deserialize)]
1920pub struct PoolConfig {
1921    /// Minimum connections per node
1922    pub min_connections: usize,
1923    /// Maximum connections per node
1924    pub max_connections: usize,
1925    /// Connection idle timeout (seconds)
1926    pub idle_timeout_secs: u64,
1927    /// Maximum connection lifetime (seconds)
1928    pub max_lifetime_secs: u64,
1929    /// Connection acquire timeout (seconds)
1930    pub acquire_timeout_secs: u64,
1931    /// Test connection before use
1932    pub test_on_acquire: bool,
1933}
1934
1935impl Default for PoolConfig {
1936    fn default() -> Self {
1937        Self {
1938            min_connections: 2,
1939            max_connections: 100,
1940            idle_timeout_secs: 300,
1941            max_lifetime_secs: 1800,
1942            acquire_timeout_secs: 30,
1943            test_on_acquire: true,
1944        }
1945    }
1946}
1947
1948impl PoolConfig {
1949    /// Get idle timeout as Duration
1950    pub fn idle_timeout(&self) -> Duration {
1951        Duration::from_secs(self.idle_timeout_secs)
1952    }
1953
1954    /// Get max lifetime as Duration
1955    pub fn max_lifetime(&self) -> Duration {
1956        Duration::from_secs(self.max_lifetime_secs)
1957    }
1958
1959    /// Get acquire timeout as Duration
1960    pub fn acquire_timeout(&self) -> Duration {
1961        Duration::from_secs(self.acquire_timeout_secs)
1962    }
1963}
1964
1965/// Load balancer configuration
1966#[derive(Debug, Clone, Serialize, Deserialize)]
1967pub struct LoadBalancerConfig {
1968    /// Routing strategy for read queries
1969    pub read_strategy: Strategy,
1970    /// Enable read/write splitting
1971    pub read_write_split: bool,
1972    /// Latency threshold for unhealthy marking (ms)
1973    pub latency_threshold_ms: u64,
1974}
1975
1976impl Default for LoadBalancerConfig {
1977    fn default() -> Self {
1978        Self {
1979            read_strategy: Strategy::RoundRobin,
1980            read_write_split: true,
1981            latency_threshold_ms: 100,
1982        }
1983    }
1984}
1985
1986/// Load balancing strategy
1987#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1988#[serde(rename_all = "snake_case")]
1989pub enum Strategy {
1990    /// Round-robin across nodes
1991    RoundRobin,
1992    /// Weighted round-robin
1993    WeightedRoundRobin,
1994    /// Route to least loaded node
1995    LeastConnections,
1996    /// Route to lowest latency node
1997    LatencyBased,
1998    /// Random selection
1999    Random,
2000}
2001
2002/// Health check configuration
2003#[derive(Debug, Clone, Serialize, Deserialize)]
2004pub struct HealthConfig {
2005    /// Check interval (seconds)
2006    pub check_interval_secs: u64,
2007    /// Check timeout (seconds)
2008    pub check_timeout_secs: u64,
2009    /// Failures before marking unhealthy
2010    pub failure_threshold: u32,
2011    /// Successes before marking healthy
2012    pub success_threshold: u32,
2013    /// Health check query
2014    pub check_query: String,
2015}
2016
2017impl Default for HealthConfig {
2018    fn default() -> Self {
2019        Self {
2020            check_interval_secs: 5,
2021            check_timeout_secs: 3,
2022            failure_threshold: 3,
2023            success_threshold: 2,
2024            check_query: "SELECT 1".to_string(),
2025        }
2026    }
2027}
2028
2029impl HealthConfig {
2030    /// Get check interval as Duration
2031    pub fn check_interval(&self) -> Duration {
2032        Duration::from_secs(self.check_interval_secs)
2033    }
2034
2035    /// Get check timeout as Duration
2036    pub fn check_timeout(&self) -> Duration {
2037        Duration::from_secs(self.check_timeout_secs)
2038    }
2039}
2040
2041/// Backend node configuration
2042#[derive(Debug, Clone, Serialize, Deserialize)]
2043pub struct NodeConfig {
2044    /// Node host
2045    pub host: String,
2046    /// Node port (PostgreSQL protocol)
2047    pub port: u16,
2048    /// Node HTTP API port (for SQL API forwarding)
2049    /// Defaults to 8080 if not specified
2050    #[serde(default = "default_http_port")]
2051    pub http_port: u16,
2052    /// Node role
2053    pub role: NodeRole,
2054    /// Weight for load balancing
2055    pub weight: u32,
2056    /// Whether node is enabled
2057    pub enabled: bool,
2058    /// Optional node name for logging
2059    pub name: Option<String>,
2060}
2061
2062fn default_http_port() -> u16 {
2063    8080
2064}
2065
2066impl NodeConfig {
2067    /// Get address string
2068    pub fn address(&self) -> String {
2069        format!("{}:{}", self.host, self.port)
2070    }
2071
2072    /// Get display name
2073    pub fn display_name(&self) -> &str {
2074        self.name.as_deref().unwrap_or(&self.host)
2075    }
2076}
2077
2078/// Node role
2079#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2080#[serde(rename_all = "lowercase")]
2081pub enum NodeRole {
2082    /// Primary node (accepts writes)
2083    Primary,
2084    /// Standby node (can be promoted)
2085    Standby,
2086    /// Read replica (read-only, cannot be promoted)
2087    #[serde(rename = "replica")]
2088    ReadReplica,
2089}
2090
2091/// TLS configuration
2092#[derive(Debug, Clone, Serialize, Deserialize)]
2093pub struct TlsConfig {
2094    /// Enable TLS for client connections
2095    pub enabled: bool,
2096    /// Path to certificate file
2097    pub cert_path: String,
2098    /// Path to private key file
2099    pub key_path: String,
2100    /// Path to CA certificate (for client verification)
2101    pub ca_path: Option<String>,
2102    /// Require client certificates
2103    pub require_client_cert: bool,
2104}
2105
2106#[cfg(test)]
2107mod tests {
2108    use super::*;
2109
2110    #[test]
2111    fn test_default_config() {
2112        let config = ProxyConfig::default();
2113        assert_eq!(config.listen_address, "0.0.0.0:5432");
2114        assert!(config.tr_enabled);
2115    }
2116
2117    #[test]
2118    fn test_add_node() {
2119        let mut config = ProxyConfig::default();
2120        config.add_node("localhost:5432", "primary").unwrap();
2121        config.add_node("localhost:5433", "standby").unwrap();
2122
2123        assert_eq!(config.nodes.len(), 2);
2124        assert!(config.primary_node().is_some());
2125        assert_eq!(config.standby_nodes().len(), 1);
2126    }
2127
2128    // -------------------------------------------------------------------------
2129    // Env-var substitution (`${VAR}` / `${VAR:-default}`)
2130    //
2131    // Tests use `HELIOS_SUBST_TEST_*` variable names that no shipped config
2132    // references, so they cannot perturb `test_all_shipped_configs_parse`
2133    // (Cargo runs tests in parallel threads that share one process env).
2134    // -------------------------------------------------------------------------
2135
2136    #[test]
2137    fn test_substitute_env_set_value_wins() {
2138        std::env::set_var("HELIOS_SUBST_TEST_SET", "hello");
2139        // `${NAME:-default}`: a set var beats the default.
2140        assert_eq!(
2141            substitute_env("x = \"${HELIOS_SUBST_TEST_SET:-fallback}\"").unwrap(),
2142            "x = \"hello\""
2143        );
2144        // `${NAME}`: bare form uses the set value.
2145        assert_eq!(
2146            substitute_env("x = \"${HELIOS_SUBST_TEST_SET}\"").unwrap(),
2147            "x = \"hello\""
2148        );
2149        std::env::remove_var("HELIOS_SUBST_TEST_SET");
2150    }
2151
2152    #[test]
2153    fn test_substitute_env_default_fallback() {
2154        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_A");
2155        assert_eq!(
2156            substitute_env("s = \"${HELIOS_SUBST_TEST_UNSET_A:-abc}\"").unwrap(),
2157            "s = \"abc\""
2158        );
2159    }
2160
2161    #[test]
2162    fn test_substitute_env_empty_default() {
2163        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_B");
2164        assert_eq!(
2165            substitute_env("s = \"${HELIOS_SUBST_TEST_UNSET_B:-}\"").unwrap(),
2166            "s = \"\""
2167        );
2168    }
2169
2170    #[test]
2171    fn test_substitute_env_missing_no_default_errors() {
2172        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_C");
2173        let err = substitute_env("s = \"${HELIOS_SUBST_TEST_UNSET_C}\"").unwrap_err();
2174        let msg = err.to_string();
2175        assert!(
2176            msg.contains("HELIOS_SUBST_TEST_UNSET_C"),
2177            "error must name the missing variable, got: {msg}"
2178        );
2179    }
2180
2181    #[test]
2182    fn test_substitute_env_skips_full_line_comments() {
2183        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_D");
2184        // A commented line carrying a bare `${VAR}` (no default) must neither
2185        // error nor be substituted — it is copied verbatim.
2186        let input = "  # default_password = \"${HELIOS_SUBST_TEST_UNSET_D}\"\nx = 1\n";
2187        assert_eq!(substitute_env(input).unwrap(), input);
2188    }
2189
2190    #[test]
2191    fn test_substitute_env_multiple_on_one_line() {
2192        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_E");
2193        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_F");
2194        assert_eq!(
2195            substitute_env(
2196                "addr = \"${HELIOS_SUBST_TEST_UNSET_E:-host}:${HELIOS_SUBST_TEST_UNSET_F:-5432}\""
2197            )
2198            .unwrap(),
2199            "addr = \"host:5432\""
2200        );
2201    }
2202
2203    #[test]
2204    fn test_substitute_env_unquoted_numeric_position() {
2205        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_G");
2206        // Unquoted `${..:-50}` must become the bare token `50` (valid TOML).
2207        let out = substitute_env("max_connections = ${HELIOS_SUBST_TEST_UNSET_G:-50}").unwrap();
2208        assert_eq!(out, "max_connections = 50");
2209        // ...and that must now deserialize as an integer, not a string.
2210        #[derive(serde::Deserialize)]
2211        struct P {
2212            max_connections: u32,
2213        }
2214        let p: P = toml::from_str(&out).unwrap();
2215        assert_eq!(p.max_connections, 50);
2216    }
2217
2218    #[test]
2219    fn test_substitute_env_leaves_malformed_literal() {
2220        // A `$` not opening a valid `${NAME...}` is left untouched.
2221        assert_eq!(substitute_env("cost = $5.00\n").unwrap(), "cost = $5.00\n");
2222        std::env::remove_var("HELIOS_SUBST_TEST_UNSET_H");
2223        // Unterminated placeholder is left literal (no error).
2224        assert_eq!(
2225            substitute_env("x = \"${HELIOS_SUBST_TEST_UNSET_H:-oops\"").unwrap(),
2226            "x = \"${HELIOS_SUBST_TEST_UNSET_H:-oops\""
2227        );
2228    }
2229
2230    // -------------------------------------------------------------------------
2231    // Unknown top-level key detection
2232    // -------------------------------------------------------------------------
2233
2234    #[test]
2235    fn test_unknown_top_level_keys_detection() {
2236        let text = "listen_address = \"x\"\n\
2237                    [pool]\nmin_connections = 1\n\
2238                    [ha]\nenabled = true\n\
2239                    [logging]\nlevel = \"info\"\n";
2240        // `listen_address` and `pool` are known; `ha` and `logging` are not.
2241        assert_eq!(
2242            unknown_top_level_keys(text),
2243            vec!["ha".to_string(), "logging".to_string()]
2244        );
2245    }
2246
2247    #[test]
2248    fn test_unknown_top_level_keys_nested_are_out_of_scope() {
2249        // `[cache.l1]` is a NESTED unknown key under the known `cache` table;
2250        // it must NOT be reported (nested detection is out of scope).
2251        let text = "[cache]\nenabled = true\n[cache.l1]\nsize = 500\n";
2252        assert!(unknown_top_level_keys(text).is_empty());
2253    }
2254
2255    #[test]
2256    fn test_known_top_level_keys_cover_struct_fields() {
2257        // Drift guard: every key a default `ProxyConfig` serialises to must be
2258        // present in `KNOWN_TOP_LEVEL_KEYS`. `Option` fields that default to
2259        // `None` (`tls`, `admin_token`) are absent from the serialised form, so
2260        // this is a subset check — it still catches a new non-optional field
2261        // added without updating the list.
2262        let value = toml::Value::try_from(ProxyConfig::default()).unwrap();
2263        let table = value.as_table().unwrap();
2264        for k in table.keys() {
2265            assert!(
2266                KNOWN_TOP_LEVEL_KEYS.contains(&k.as_str()),
2267                "field '{k}' is present in a serialised default ProxyConfig but \
2268                 missing from KNOWN_TOP_LEVEL_KEYS"
2269            );
2270        }
2271    }
2272
2273    // -------------------------------------------------------------------------
2274    // CI guard: every shipped config must load after substitution
2275    // -------------------------------------------------------------------------
2276
2277    #[test]
2278    fn test_all_shipped_configs_parse() {
2279        // For every `config/*.toml` and `scripts/regress/*.toml`: run env
2280        // substitution (with NO env vars set → `:-default` fallbacks) and assert
2281        // it deserializes into `ProxyConfig`.
2282        //
2283        // For the `config/*.toml` reference configs we go further and run the
2284        // FULL load path — `ProxyConfig::from_file`, which does substitution +
2285        // parse + `validate()` exactly as `heliosdb-proxy -c <file>` does. This
2286        // is the real guard behind "the shipped configs load": a deserialize-only
2287        // check passes even when `validate()` would reject the file (e.g. the
2288        // admin-loopback guard), so it would not have caught the non-loopback
2289        // `admin_address` default that made every reference config fail to start.
2290        //
2291        // The `scripts/regress/*.toml` files are intentionally deserialize-only:
2292        // they reference placeholder/non-loopback backend hosts, some enable the
2293        // `[edge]` section (which `validate()` gates on the compile-time
2294        // `edge-proxy` feature and on an `edge.home_url`), so `validate()` is not
2295        // universally applicable there. We still guarantee they parse.
2296        let manifest = env!("CARGO_MANIFEST_DIR");
2297        let config_dir = format!("{manifest}/config");
2298        let regress_dir = format!("{manifest}/scripts/regress");
2299
2300        // Reference configs: must survive the entire from_file() load path.
2301        let mut config_checked = 0usize;
2302        let entries = std::fs::read_dir(&config_dir)
2303            .unwrap_or_else(|e| panic!("config dir {config_dir} unreadable: {e}"));
2304        for entry in entries {
2305            let path = entry.unwrap().path();
2306            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
2307                continue;
2308            }
2309            let path_str = path
2310                .to_str()
2311                .unwrap_or_else(|| panic!("non-UTF-8 config path {}", path.display()));
2312            let loaded = ProxyConfig::from_file(path_str);
2313            assert!(
2314                loaded.is_ok(),
2315                "shipped config {} failed to load via from_file() \
2316                 (substitute + parse + validate): {}",
2317                path.display(),
2318                loaded.err().unwrap()
2319            );
2320            config_checked += 1;
2321        }
2322        assert!(
2323            config_checked >= 3,
2324            "expected to load at least the 3 config/*.toml files, checked {config_checked}"
2325        );
2326
2327        // Regression harness configs: must at least deserialize after
2328        // substitution (validate() deliberately skipped — see above).
2329        if let Ok(entries) = std::fs::read_dir(&regress_dir) {
2330            for entry in entries {
2331                let path = entry.unwrap().path();
2332                if path.extension().and_then(|e| e.to_str()) != Some("toml") {
2333                    continue;
2334                }
2335                let raw = std::fs::read_to_string(&path).unwrap();
2336                let substituted = substitute_env(&raw).unwrap_or_else(|e| {
2337                    panic!("env substitution failed for {}: {e}", path.display())
2338                });
2339                let parsed = toml::from_str::<ProxyConfig>(&substituted);
2340                assert!(
2341                    parsed.is_ok(),
2342                    "regress config {} failed to deserialize: {}",
2343                    path.display(),
2344                    parsed.err().unwrap()
2345                );
2346            }
2347        }
2348    }
2349
2350    #[test]
2351    fn test_validate_no_nodes() {
2352        let config = ProxyConfig::default();
2353        assert!(config.validate().is_err());
2354    }
2355
2356    #[test]
2357    fn test_validate_no_primary() {
2358        let mut config = ProxyConfig::default();
2359        config.add_node("localhost:5432", "standby").unwrap();
2360        assert!(config.validate().is_err());
2361    }
2362
2363    #[test]
2364    fn test_validate_success() {
2365        let mut config = ProxyConfig::default();
2366        config.add_node("localhost:5432", "primary").unwrap();
2367        assert!(config.validate().is_ok());
2368    }
2369
2370    // -------------------------------------------------------------------------
2371    // [anomaly] section — tunability of the in-process anomaly detector
2372    // -------------------------------------------------------------------------
2373
2374    #[test]
2375    fn test_anomaly_toml_defaults_match_historical_values() {
2376        // Every default must reproduce the detector's old hardcoded
2377        // `AnomalyConfig::default()` (and the old `MAX_SEEN_FINGERPRINTS`
2378        // const) EXACTLY, so an absent [anomaly] section changes nothing.
2379        let a = AnomalyToml::default();
2380        assert_eq!(a.rate_window_secs, 60);
2381        assert_eq!(a.spike_z_threshold, 3.0);
2382        assert_eq!(a.auth_window_secs, 60);
2383        assert_eq!(a.auth_critical_count, 10);
2384        assert_eq!(a.auth_warning_count, 5);
2385        assert_eq!(a.event_buffer_size, 1024);
2386        assert!(a.emit_novel_queries);
2387        assert_eq!(a.max_seen_fingerprints, 100_000);
2388    }
2389
2390    #[test]
2391    fn test_anomaly_toml_absent_section_uses_defaults() {
2392        // A full, valid ProxyConfig whose serialized TOML has the [anomaly]
2393        // table removed must fall back to the historical defaults via the
2394        // `#[serde(default)]` on the `anomaly` field.
2395        let mut base = ProxyConfig::default();
2396        base.add_node("localhost:5432", "primary").unwrap();
2397        let mut val = toml::Value::try_from(&base).unwrap();
2398        val.as_table_mut().unwrap().remove("anomaly");
2399        assert!(
2400            val.get("anomaly").is_none(),
2401            "anomaly section should be absent for this test"
2402        );
2403        let s = toml::to_string(&val).unwrap();
2404        let cfg: ProxyConfig = toml::from_str(&s).unwrap();
2405        let a = AnomalyToml::default();
2406        assert_eq!(cfg.anomaly.rate_window_secs, a.rate_window_secs);
2407        assert_eq!(cfg.anomaly.max_seen_fingerprints, a.max_seen_fingerprints);
2408        assert_eq!(cfg.anomaly.emit_novel_queries, a.emit_novel_queries);
2409    }
2410
2411    #[test]
2412    fn test_anomaly_toml_block_parses_and_overrides() {
2413        // A present [anomaly] block round-trips through a full ProxyConfig and
2414        // overrides every field.
2415        let mut base = ProxyConfig::default();
2416        base.add_node("localhost:5432", "primary").unwrap();
2417        base.anomaly = AnomalyToml {
2418            rate_window_secs: 30,
2419            spike_z_threshold: 4.5,
2420            auth_window_secs: 120,
2421            auth_critical_count: 20,
2422            auth_warning_count: 8,
2423            event_buffer_size: 4096,
2424            emit_novel_queries: false,
2425            max_seen_fingerprints: 250_000,
2426        };
2427        let s = toml::to_string(&base).unwrap();
2428        assert!(s.contains("[anomaly]"), "serialized config: {s}");
2429        let cfg: ProxyConfig = toml::from_str(&s).unwrap();
2430        assert_eq!(cfg.anomaly.rate_window_secs, 30);
2431        assert_eq!(cfg.anomaly.spike_z_threshold, 4.5);
2432        assert_eq!(cfg.anomaly.auth_window_secs, 120);
2433        assert_eq!(cfg.anomaly.auth_critical_count, 20);
2434        assert_eq!(cfg.anomaly.auth_warning_count, 8);
2435        assert_eq!(cfg.anomaly.event_buffer_size, 4096);
2436        assert!(!cfg.anomaly.emit_novel_queries);
2437        assert_eq!(cfg.anomaly.max_seen_fingerprints, 250_000);
2438    }
2439
2440    #[test]
2441    fn test_anomaly_toml_partial_block_fills_rest_from_defaults() {
2442        // A partial [anomaly] section overrides only the listed keys; the rest
2443        // fall back to the per-field serde defaults. Parsed as the section
2444        // struct directly (ProxyConfig's own top-level fields are required).
2445        let a: AnomalyToml = toml::from_str("spike_z_threshold = 5.0\n").unwrap();
2446        assert_eq!(a.spike_z_threshold, 5.0);
2447        assert_eq!(a.rate_window_secs, 60);
2448        assert_eq!(a.event_buffer_size, 1024);
2449        assert_eq!(a.max_seen_fingerprints, 100_000);
2450    }
2451
2452    #[test]
2453    fn test_validate_rejects_degenerate_anomaly_values() {
2454        let base = || {
2455            let mut c = ProxyConfig::default();
2456            c.add_node("localhost:5432", "primary").unwrap();
2457            c
2458        };
2459        // Sanity: the untouched defaults validate.
2460        assert!(base().validate().is_ok());
2461
2462        // rate_window_secs = 0 -> rejected with a clear message.
2463        let mut c = base();
2464        c.anomaly.rate_window_secs = 0;
2465        let err = c.validate().unwrap_err().to_string();
2466        assert!(
2467            err.contains("anomaly.rate_window_secs"),
2468            "unexpected error: {err}"
2469        );
2470
2471        // auth_window_secs = 0 -> rejected.
2472        let mut c = base();
2473        c.anomaly.auth_window_secs = 0;
2474        assert!(c.validate().is_err());
2475
2476        // event_buffer_size = 0 -> rejected.
2477        let mut c = base();
2478        c.anomaly.event_buffer_size = 0;
2479        assert!(c.validate().is_err());
2480
2481        // max_seen_fingerprints = 0 -> rejected.
2482        let mut c = base();
2483        c.anomaly.max_seen_fingerprints = 0;
2484        assert!(c.validate().is_err());
2485
2486        // non-finite / non-positive z threshold -> rejected.
2487        let mut c = base();
2488        c.anomaly.spike_z_threshold = 0.0;
2489        assert!(c.validate().is_err());
2490        let mut c = base();
2491        c.anomaly.spike_z_threshold = f64::NAN;
2492        assert!(c.validate().is_err());
2493
2494        // warning count above critical count inverts the ladder -> rejected.
2495        let mut c = base();
2496        c.anomaly.auth_warning_count = 11;
2497        c.anomaly.auth_critical_count = 10;
2498        assert!(c.validate().is_err());
2499
2500        // auth_critical_count = 0 would fire Critical on the first failed auth.
2501        let mut c = base();
2502        c.anomaly.auth_critical_count = 0;
2503        c.anomaly.auth_warning_count = 0; // keep warning <= critical
2504        let err = c.validate().unwrap_err().to_string();
2505        assert!(
2506            err.contains("anomaly.auth_critical_count"),
2507            "unexpected error: {err}"
2508        );
2509    }
2510
2511    #[cfg(feature = "anomaly-detection")]
2512    #[test]
2513    fn test_anomaly_toml_to_anomaly_config_roundtrip() {
2514        // The conversion is field-for-field; defaults must equal the runtime
2515        // detector's own default, and explicit values must carry through.
2516        let default_rt = AnomalyToml::default().to_anomaly_config();
2517        let expected = crate::anomaly::AnomalyConfig::default();
2518        assert_eq!(default_rt.rate_window_secs, expected.rate_window_secs);
2519        assert_eq!(default_rt.spike_z_threshold, expected.spike_z_threshold);
2520        assert_eq!(default_rt.auth_window_secs, expected.auth_window_secs);
2521        assert_eq!(default_rt.auth_critical_count, expected.auth_critical_count);
2522        assert_eq!(default_rt.auth_warning_count, expected.auth_warning_count);
2523        assert_eq!(default_rt.event_buffer_size, expected.event_buffer_size);
2524        assert_eq!(default_rt.emit_novel_queries, expected.emit_novel_queries);
2525        assert_eq!(
2526            default_rt.max_seen_fingerprints,
2527            expected.max_seen_fingerprints
2528        );
2529
2530        let toml = AnomalyToml {
2531            rate_window_secs: 15,
2532            spike_z_threshold: 2.5,
2533            auth_window_secs: 90,
2534            auth_critical_count: 12,
2535            auth_warning_count: 6,
2536            event_buffer_size: 2048,
2537            emit_novel_queries: false,
2538            max_seen_fingerprints: 500_000,
2539        };
2540        let rt = toml.to_anomaly_config();
2541        assert_eq!(rt.rate_window_secs, 15);
2542        assert_eq!(rt.spike_z_threshold, 2.5);
2543        assert_eq!(rt.auth_window_secs, 90);
2544        assert_eq!(rt.auth_critical_count, 12);
2545        assert_eq!(rt.auth_warning_count, 6);
2546        assert_eq!(rt.event_buffer_size, 2048);
2547        assert!(!rt.emit_novel_queries);
2548        assert_eq!(rt.max_seen_fingerprints, 500_000);
2549    }
2550
2551    #[test]
2552    fn test_validate_refuses_anonymous_nonloopback_admin() {
2553        let base = || {
2554            let mut c = ProxyConfig::default();
2555            c.add_node("localhost:5432", "primary").unwrap();
2556            c
2557        };
2558        // Loopback + no token: allowed (the default).
2559        let mut c = base();
2560        c.admin_address = "127.0.0.1:9090".to_string();
2561        assert!(c.validate().is_ok());
2562        // Non-loopback + no token: REFUSED.
2563        let mut c = base();
2564        c.admin_address = "0.0.0.0:9090".to_string();
2565        assert!(
2566            c.validate().is_err(),
2567            "anonymous 0.0.0.0 admin must be refused"
2568        );
2569        // Non-loopback + token: allowed.
2570        let mut c = base();
2571        c.admin_address = "0.0.0.0:9090".to_string();
2572        c.admin_token = Some("secret".to_string());
2573        assert!(c.validate().is_ok());
2574        // Non-loopback + explicit opt-in: allowed.
2575        let mut c = base();
2576        c.admin_address = "0.0.0.0:9090".to_string();
2577        c.admin_allow_insecure = true;
2578        assert!(c.validate().is_ok());
2579    }
2580
2581    #[test]
2582    fn test_validate_rejects_zero_health_interval() {
2583        // A zero health-check interval panics tokio::time::interval; validation
2584        // must reject it up front rather than let the health task die silently.
2585        let mut config = ProxyConfig::default();
2586        config.add_node("localhost:5432", "primary").unwrap();
2587        config.health.check_interval_secs = 0;
2588        assert!(config.validate().is_err());
2589        config.health.check_interval_secs = 1;
2590        assert!(config.validate().is_ok());
2591    }
2592
2593    // -------------------------------------------------------------------------
2594    // [limits] section (operational safety bounds, formerly hardcoded consts)
2595    // -------------------------------------------------------------------------
2596
2597    #[test]
2598    fn test_limits_defaults_equal_prior_constants() {
2599        // Every default MUST reproduce the exact value the constant held in
2600        // src/server.rs, so an existing deployment with no `[limits]` block is
2601        // byte-for-byte unchanged.
2602        let l = LimitsToml::default();
2603        assert_eq!(l.max_cancel_keys, 100_000);
2604        assert_eq!(l.startup_timeout_secs, 30);
2605        assert_eq!(l.backend_write_timeout_secs, 30);
2606        assert_eq!(l.backend_read_timeout_secs, 30);
2607        assert_eq!(l.client_write_timeout_secs, 60);
2608        assert_eq!(l.reprepare_timeout_secs, 15);
2609        assert_eq!(l.max_prepared_statements, 8192);
2610        assert_eq!(l.max_prepared_bytes, 64 * 1024 * 1024);
2611        assert_eq!(l.max_pending_bytes, 64 * 1024 * 1024);
2612        assert_eq!(l.max_total_idle_backend_conns, 8192);
2613        assert_eq!(l.pool_reap_interval_secs, 30);
2614        // And the field on a default ProxyConfig matches.
2615        assert_eq!(
2616            ProxyConfig::default().limits.max_prepared_bytes,
2617            64 * 1024 * 1024
2618        );
2619    }
2620
2621    #[test]
2622    fn test_limits_toml_partial_overrides_and_fills_defaults() {
2623        // Parsing a partial `[limits]` table (as `LimitsToml`) overrides the
2624        // listed keys and fills the rest from the per-field serde defaults.
2625        let limits: LimitsToml = toml::from_str(
2626            "startup_timeout_secs = 5\nmax_prepared_statements = 100\nmax_cancel_keys = 42\n",
2627        )
2628        .expect("parse partial LimitsToml");
2629        // Overridden.
2630        assert_eq!(limits.startup_timeout_secs, 5);
2631        assert_eq!(limits.max_prepared_statements, 100);
2632        assert_eq!(limits.max_cancel_keys, 42);
2633        // Untouched keys keep their const defaults.
2634        assert_eq!(limits.client_write_timeout_secs, 60);
2635        assert_eq!(limits.max_pending_bytes, 64 * 1024 * 1024);
2636        assert_eq!(limits.pool_reap_interval_secs, 30);
2637    }
2638
2639    #[test]
2640    fn test_proxyconfig_partial_limits_section_overrides() {
2641        // Full ProxyConfig load path: a `[limits]` section with only some keys
2642        // set overrides those and defaults the rest. Built from a serialized
2643        // default config so the required non-limits fields are all present.
2644        let mut val = toml::Value::try_from(ProxyConfig::default()).unwrap();
2645        let mut partial = toml::value::Table::new();
2646        partial.insert("startup_timeout_secs".into(), toml::Value::Integer(5));
2647        partial.insert("max_cancel_keys".into(), toml::Value::Integer(42));
2648        val.as_table_mut()
2649            .unwrap()
2650            .insert("limits".into(), toml::Value::Table(partial));
2651        let text = toml::to_string(&val).unwrap();
2652        let cfg: ProxyConfig = toml::from_str(&text).expect("parse config with partial [limits]");
2653        assert_eq!(cfg.limits.startup_timeout_secs, 5);
2654        assert_eq!(cfg.limits.max_cancel_keys, 42);
2655        // Unset key defaults.
2656        assert_eq!(cfg.limits.client_write_timeout_secs, 60);
2657    }
2658
2659    #[test]
2660    fn test_proxyconfig_absent_limits_section_is_default() {
2661        // A config with NO `[limits]` table at all → the whole section defaults
2662        // (the `#[serde(default)]` on the field). Built by stripping `limits`
2663        // from a serialized default config.
2664        let mut val = toml::Value::try_from(ProxyConfig::default()).unwrap();
2665        val.as_table_mut().unwrap().remove("limits");
2666        assert!(val.as_table().unwrap().get("limits").is_none());
2667        let text = toml::to_string(&val).unwrap();
2668        let cfg: ProxyConfig = toml::from_str(&text).expect("parse config without [limits]");
2669        assert_eq!(cfg.limits.startup_timeout_secs, 30);
2670        assert_eq!(cfg.limits.max_total_idle_backend_conns, 8192);
2671        assert_eq!(cfg.limits.pool_reap_interval_secs, 30);
2672    }
2673
2674    #[test]
2675    fn test_validate_rejects_zero_limits() {
2676        let base = || {
2677            let mut c = ProxyConfig::default();
2678            c.add_node("localhost:5432", "primary").unwrap();
2679            c
2680        };
2681        // A pristine config validates.
2682        assert!(base().validate().is_ok());
2683
2684        // Each timeout at 0 is rejected.
2685        let mut c = base();
2686        c.limits.startup_timeout_secs = 0;
2687        assert!(
2688            c.validate().is_err(),
2689            "zero startup_timeout must be rejected"
2690        );
2691
2692        let mut c = base();
2693        c.limits.backend_write_timeout_secs = 0;
2694        assert!(c.validate().is_err());
2695
2696        let mut c = base();
2697        c.limits.client_write_timeout_secs = 0;
2698        assert!(c.validate().is_err());
2699
2700        let mut c = base();
2701        c.limits.reprepare_timeout_secs = 0;
2702        assert!(c.validate().is_err());
2703
2704        let mut c = base();
2705        c.limits.pool_reap_interval_secs = 0;
2706        assert!(c.validate().is_err());
2707
2708        // Each cap at 0 is rejected.
2709        let mut c = base();
2710        c.limits.max_cancel_keys = 0;
2711        assert!(c.validate().is_err());
2712
2713        let mut c = base();
2714        c.limits.max_prepared_statements = 0;
2715        assert!(c.validate().is_err());
2716
2717        let mut c = base();
2718        c.limits.max_prepared_bytes = 0;
2719        assert!(c.validate().is_err());
2720
2721        let mut c = base();
2722        c.limits.max_pending_bytes = 0;
2723        assert!(c.validate().is_err());
2724
2725        let mut c = base();
2726        c.limits.max_total_idle_backend_conns = 0;
2727        assert!(c.validate().is_err());
2728    }
2729
2730    #[test]
2731    fn test_validate_rejects_over_bound_limit_secs() {
2732        let base = || {
2733            let mut c = ProxyConfig::default();
2734            c.add_node("localhost:5432", "primary").unwrap();
2735            c
2736        };
2737        // A value at the ceiling is accepted; one past it is rejected because it
2738        // would overflow the connect-time `Instant + Duration` and panic the
2739        // per-connection task.
2740        let mut c = base();
2741        c.limits.startup_timeout_secs = MAX_LIMIT_SECS;
2742        assert!(
2743            c.validate().is_ok(),
2744            "startup_timeout_secs at MAX_LIMIT_SECS must be accepted"
2745        );
2746
2747        // Every `*_secs` key that feeds a Duration/Instant must reject u64::MAX.
2748        let mut c = base();
2749        c.limits.startup_timeout_secs = u64::MAX;
2750        assert!(
2751            c.validate().is_err(),
2752            "over-bound startup_timeout_secs must be rejected"
2753        );
2754
2755        let mut c = base();
2756        c.limits.backend_write_timeout_secs = MAX_LIMIT_SECS + 1;
2757        assert!(c.validate().is_err());
2758
2759        let mut c = base();
2760        c.limits.backend_read_timeout_secs = u64::MAX;
2761        assert!(c.validate().is_err());
2762
2763        let mut c = base();
2764        c.limits.client_write_timeout_secs = u64::MAX;
2765        assert!(c.validate().is_err());
2766
2767        let mut c = base();
2768        c.limits.reprepare_timeout_secs = u64::MAX;
2769        assert!(c.validate().is_err());
2770
2771        let mut c = base();
2772        c.limits.pool_reap_interval_secs = u64::MAX;
2773        assert!(c.validate().is_err());
2774    }
2775
2776    #[test]
2777    fn test_validate_edge_disabled_section_is_inert() {
2778        // The default [edge] section (enabled = false) must never affect
2779        // validation, whatever features the binary was built with.
2780        let mut config = ProxyConfig::default();
2781        config.add_node("localhost:5432", "primary").unwrap();
2782        assert!(!config.edge.enabled);
2783        assert!(config.validate().is_ok());
2784    }
2785
2786    #[test]
2787    fn test_validate_edge_enabled_requires_feature() {
2788        let mut config = ProxyConfig::default();
2789        config.add_node("localhost:5432", "primary").unwrap();
2790        config.edge.enabled = true;
2791        // role=home needs nothing beyond the compile-time feature.
2792        if cfg!(feature = "edge-proxy") {
2793            assert!(config.validate().is_ok());
2794        } else {
2795            assert!(
2796                config.validate().is_err(),
2797                "edge.enabled on a build without the edge-proxy feature must be refused"
2798            );
2799        }
2800    }
2801
2802    #[cfg(feature = "edge-proxy")]
2803    #[test]
2804    fn test_validate_edge_rejects_zero_intervals() {
2805        // Zero GC cadence panics tokio::time::interval; zero liveness
2806        // window prunes every edge on the first sweep. Both refused.
2807        let base = || {
2808            let mut c = ProxyConfig::default();
2809            c.add_node("localhost:5432", "primary").unwrap();
2810            c.edge.enabled = true;
2811            c
2812        };
2813        let mut c = base();
2814        c.edge.subscribe_gc_secs = 0;
2815        assert!(c.validate().is_err());
2816        let mut c = base();
2817        c.edge.liveness_window_secs = 0;
2818        assert!(c.validate().is_err());
2819        // Only enforced when the section is enabled: an inert config
2820        // with odd values must not fail validation.
2821        let mut c = base();
2822        c.edge.enabled = false;
2823        c.edge.subscribe_gc_secs = 0;
2824        assert!(c.validate().is_ok());
2825    }
2826
2827    #[cfg(feature = "edge-proxy")]
2828    #[test]
2829    fn test_validate_edge_role_requires_home_url() {
2830        let base = || {
2831            let mut c = ProxyConfig::default();
2832            c.add_node("localhost:5432", "primary").unwrap();
2833            c.edge.enabled = true;
2834            c.edge.role = crate::edge::EdgeRole::Edge;
2835            c
2836        };
2837        // role=edge without home_url: refused, and the message says why.
2838        let c = base();
2839        let err = c.validate().unwrap_err().to_string();
2840        assert!(err.contains("home_url"), "unexpected error: {}", err);
2841        // role=edge + home_url (+ the node from base): allowed.
2842        let mut c = base();
2843        c.edge.home_url = "http://home-proxy:9090".to_string();
2844        assert!(c.validate().is_ok());
2845    }
2846
2847    #[cfg(feature = "edge-proxy")]
2848    #[test]
2849    fn test_validate_edge_zero_ttl_refused_when_enabled() {
2850        let mut c = ProxyConfig::default();
2851        c.add_node("localhost:5432", "primary").unwrap();
2852        c.edge.enabled = true;
2853        c.edge.default_ttl_secs = 0;
2854        let err = c.validate().unwrap_err().to_string();
2855        assert!(
2856            err.contains("default_ttl_secs"),
2857            "unexpected error: {}",
2858            err
2859        );
2860        // Inert section: odd values tolerated when disabled.
2861        c.edge.enabled = false;
2862        assert!(c.validate().is_ok());
2863    }
2864
2865    #[cfg(feature = "edge-proxy")]
2866    #[test]
2867    fn test_validate_edge_token_requires_https_home_url() {
2868        let base = || {
2869            let mut c = ProxyConfig::default();
2870            c.add_node("localhost:5432", "primary").unwrap();
2871            c.edge.enabled = true;
2872            c.edge.role = crate::edge::EdgeRole::Edge;
2873            c.edge.home_url = "http://home-proxy:9090".to_string();
2874            c
2875        };
2876        // Tokenless plain-http: fine (no credential to leak).
2877        assert!(base().validate().is_ok());
2878        // Token over plain http: refused, message names both remedies.
2879        let mut c = base();
2880        c.edge.auth_token = "secret".to_string();
2881        let err = c.validate().unwrap_err().to_string();
2882        assert!(err.contains("https"), "unexpected error: {}", err);
2883        assert!(err.contains("allow_insecure_home_url"), "{}", err);
2884        // Explicit opt-out for private links: allowed.
2885        let mut c = base();
2886        c.edge.auth_token = "secret".to_string();
2887        c.edge.allow_insecure_home_url = true;
2888        assert!(c.validate().is_ok());
2889        // Token over https: allowed.
2890        let mut c = base();
2891        c.edge.auth_token = "secret".to_string();
2892        c.edge.home_url = "https://home-proxy:9090".to_string();
2893        assert!(c.validate().is_ok());
2894    }
2895
2896    #[cfg(all(feature = "edge-proxy", feature = "query-cache"))]
2897    #[test]
2898    fn test_validate_edge_role_rejects_query_cache_combo() {
2899        // The query-result cache never hears SSE invalidations — on an
2900        // edge it would serve stale rows past the edge coherence bound.
2901        let mut c = ProxyConfig::default();
2902        c.add_node("localhost:5432", "primary").unwrap();
2903        c.edge.enabled = true;
2904        c.edge.role = crate::edge::EdgeRole::Edge;
2905        c.edge.home_url = "https://home-proxy:9090".to_string();
2906        c.cache.enabled = true;
2907        let err = c.validate().unwrap_err().to_string();
2908        assert!(err.contains("[cache]"), "unexpected error: {}", err);
2909        // Home role + query-cache stays permitted (local writes
2910        // invalidate both caches on the same path).
2911        c.edge.role = crate::edge::EdgeRole::Home;
2912        c.edge.home_url.clear();
2913        assert!(c.validate().is_ok());
2914        // Edge role with the cache disabled is fine.
2915        c.edge.role = crate::edge::EdgeRole::Edge;
2916        c.edge.home_url = "https://home-proxy:9090".to_string();
2917        c.cache.enabled = false;
2918        assert!(c.validate().is_ok());
2919    }
2920
2921    #[test]
2922    fn test_pool_config_durations() {
2923        let config = PoolConfig::default();
2924        assert_eq!(config.idle_timeout(), Duration::from_secs(300));
2925        assert_eq!(config.max_lifetime(), Duration::from_secs(1800));
2926    }
2927
2928    #[test]
2929    fn test_pool_mode_default() {
2930        let config = PoolModeConfig::default();
2931        assert_eq!(config.mode, PoolingMode::Session);
2932        assert_eq!(config.max_pool_size, 100);
2933        assert_eq!(config.min_idle, 10);
2934        assert_eq!(config.reset_query, "DISCARD ALL");
2935    }
2936
2937    #[test]
2938    fn test_pool_mode_session() {
2939        let config = PoolModeConfig::session_mode();
2940        assert_eq!(config.mode, PoolingMode::Session);
2941        assert_eq!(config.prepared_statement_mode, PreparedStatementMode::Named);
2942    }
2943
2944    #[test]
2945    fn test_pool_mode_transaction() {
2946        let config = PoolModeConfig::transaction_mode();
2947        assert_eq!(config.mode, PoolingMode::Transaction);
2948        assert_eq!(config.prepared_statement_mode, PreparedStatementMode::Track);
2949    }
2950
2951    #[test]
2952    fn test_pool_mode_statement() {
2953        let config = PoolModeConfig::statement_mode();
2954        assert_eq!(config.mode, PoolingMode::Statement);
2955        assert_eq!(
2956            config.prepared_statement_mode,
2957            PreparedStatementMode::Disable
2958        );
2959    }
2960
2961    #[test]
2962    fn test_pool_mode_durations() {
2963        let config = PoolModeConfig::default();
2964        assert_eq!(config.idle_timeout(), Duration::from_secs(600));
2965        assert_eq!(config.max_lifetime(), Duration::from_secs(3600));
2966        assert_eq!(config.acquire_timeout(), Duration::from_secs(5));
2967    }
2968
2969    #[test]
2970    fn test_proxy_config_has_pool_mode() {
2971        let config = ProxyConfig::default();
2972        assert_eq!(config.pool_mode.mode, PoolingMode::Session);
2973    }
2974
2975    /// `plugins` defaults to `enabled = false` so adding the field to
2976    /// `ProxyConfig` doesn't spontaneously turn on the plugin subsystem
2977    /// for existing deployments.
2978    #[test]
2979    fn test_plugin_toml_default_is_disabled() {
2980        let config = ProxyConfig::default();
2981        assert!(!config.plugins.enabled);
2982        assert_eq!(config.plugins.plugin_dir, "/etc/heliosproxy/plugins");
2983        assert_eq!(config.plugins.memory_limit_mb, 64);
2984        assert_eq!(config.plugins.timeout_ms, 100);
2985    }
2986
2987    /// Existing TOML configs (written before this field existed) must
2988    /// round-trip through `Deserialize` without failing. The `plugins`
2989    /// section is `#[serde(default)]`, so omitting it yields the default.
2990    #[test]
2991    fn test_proxy_config_toml_without_plugins_section_still_parses() {
2992        let toml_text = r#"
2993            listen_address = "0.0.0.0:5432"
2994            admin_address = "0.0.0.0:9090"
2995            tr_enabled = true
2996            tr_mode = "session"
2997            nodes = []
2998
2999            [pool]
3000            min_connections = 2
3001            max_connections = 10
3002            idle_timeout_secs = 300
3003            max_lifetime_secs = 1800
3004            acquire_timeout_secs = 30
3005            test_on_acquire = true
3006
3007            [load_balancer]
3008            read_strategy = "round_robin"
3009            read_write_split = true
3010            latency_threshold_ms = 100
3011
3012            [health]
3013            check_interval_secs = 5
3014            check_timeout_secs = 3
3015            failure_threshold = 3
3016            success_threshold = 2
3017            check_query = "SELECT 1"
3018        "#;
3019        let config: ProxyConfig = toml::from_str(toml_text).expect("parse");
3020        assert!(!config.plugins.enabled);
3021    }
3022
3023    /// A `[plugins]` section with overrides round-trips and populates the
3024    /// struct correctly.
3025    #[test]
3026    fn test_plugin_toml_overrides_parse() {
3027        let toml_text = r#"
3028            listen_address = "0.0.0.0:5432"
3029            admin_address = "0.0.0.0:9090"
3030            tr_enabled = true
3031            tr_mode = "session"
3032            nodes = []
3033
3034            [pool]
3035            min_connections = 2
3036            max_connections = 10
3037            idle_timeout_secs = 300
3038            max_lifetime_secs = 1800
3039            acquire_timeout_secs = 30
3040            test_on_acquire = true
3041
3042            [load_balancer]
3043            read_strategy = "round_robin"
3044            read_write_split = true
3045            latency_threshold_ms = 100
3046
3047            [health]
3048            check_interval_secs = 5
3049            check_timeout_secs = 3
3050            failure_threshold = 3
3051            success_threshold = 2
3052            check_query = "SELECT 1"
3053
3054            [plugins]
3055            enabled = true
3056            plugin_dir = "/tmp/helios-plugins"
3057            hot_reload = true
3058            memory_limit_mb = 128
3059            timeout_ms = 250
3060        "#;
3061        let config: ProxyConfig = toml::from_str(toml_text).expect("parse");
3062        assert!(config.plugins.enabled);
3063        assert_eq!(config.plugins.plugin_dir, "/tmp/helios-plugins");
3064        assert!(config.plugins.hot_reload);
3065        assert_eq!(config.plugins.memory_limit_mb, 128);
3066        assert_eq!(config.plugins.timeout_ms, 250);
3067        // Un-specified fields retain their defaults.
3068        assert_eq!(config.plugins.max_plugins, 20);
3069        assert!(config.plugins.fuel_metering);
3070    }
3071}