Skip to main content

pgwire_replication/
config.rs

1//! Configuration types for PostgreSQL replication connections.
2//!
3//! This module provides configuration structures for establishing replication
4//! connections to PostgreSQL, including TLS settings and replication parameters.
5
6use std::path::PathBuf;
7use std::time::Duration;
8
9use crate::lsn::Lsn;
10
11/// SSL/TLS connection mode.
12///
13/// These modes match PostgreSQL's `sslmode` connection parameter.
14/// See [PostgreSQL SSL Support](https://www.postgresql.org/docs/current/libpq-ssl.html)
15/// for detailed documentation.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum SslMode {
18    /// Never use TLS. Connection will fail if server requires TLS.
19    #[default]
20    Disable,
21
22    /// Try TLS first, fall back to unencrypted if server doesn't support it.
23    ///
24    /// **Warning**: Vulnerable to downgrade attacks. Not recommended for production.
25    Prefer,
26
27    /// Require TLS but don't verify the server certificate.
28    ///
29    /// Protects against passive eavesdropping but not active MITM attacks.
30    Require,
31
32    /// Require TLS and verify the server certificate chain against trusted CAs.
33    ///
34    /// Does NOT verify that the certificate hostname matches the connection target.
35    VerifyCa,
36
37    /// Require TLS, verify certificate chain, AND verify hostname matches.
38    ///
39    /// **Recommended for production**. Provides full protection against MITM attacks.
40    VerifyFull,
41}
42
43impl SslMode {
44    /// Returns `true` if this mode requires TLS (won't fall back to plain).
45    #[inline]
46    pub fn requires_tls(&self) -> bool {
47        !matches!(self, SslMode::Disable | SslMode::Prefer)
48    }
49
50    /// Returns `true` if this mode verifies the certificate chain.
51    #[inline]
52    pub fn verifies_certificate(&self) -> bool {
53        matches!(self, SslMode::VerifyCa | SslMode::VerifyFull)
54    }
55
56    /// Returns `true` if this mode verifies the server hostname.
57    #[inline]
58    pub fn verifies_hostname(&self) -> bool {
59        matches!(self, SslMode::VerifyFull)
60    }
61}
62
63/// TLS/SSL configuration for PostgreSQL connections.
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct TlsConfig {
66    /// SSL mode controlling connection security level.
67    pub mode: SslMode,
68
69    /// Path to PEM file containing trusted CA certificates.
70    ///
71    /// If `None` and verification is enabled (`VerifyCa`/`VerifyFull`),
72    /// the Mozilla root certificates (webpki-roots) are used.
73    pub ca_pem_path: Option<PathBuf>,
74
75    /// Override SNI hostname sent during TLS handshake.
76    ///
77    /// Useful when:
78    /// - Connecting via IP address but certificate has a DNS name
79    /// - Using a load balancer with different internal/external names
80    ///
81    /// If `None`, the connection `host` is used for SNI.
82    pub sni_hostname: Option<String>,
83
84    /// Path to PEM file containing client certificate chain.
85    ///
86    /// Required for mutual TLS (mTLS) authentication.
87    /// Must be paired with `client_key_pem_path`.
88    pub client_cert_pem_path: Option<PathBuf>,
89
90    /// Path to PEM file containing client private key.
91    ///
92    /// Required for mutual TLS (mTLS) authentication.
93    /// Must be paired with `client_cert_pem_path`.
94    /// Supports PKCS#8, PKCS#1 (RSA), and SEC1 (EC) formats.
95    pub client_key_pem_path: Option<PathBuf>,
96}
97
98impl TlsConfig {
99    /// Create a configuration with TLS disabled.
100    ///
101    /// # Example
102    /// ```
103    /// use pgwire_replication::config::TlsConfig;
104    ///
105    /// let tls = TlsConfig::disabled();
106    /// assert!(!tls.mode.requires_tls());
107    /// ```
108    pub fn disabled() -> Self {
109        Self::default()
110    }
111
112    /// Create a configuration requiring TLS without certificate verification.
113    ///
114    /// **Warning**: This mode is vulnerable to MITM attacks.
115    /// Use `verify_ca()` or `verify_full()` for production.
116    ///
117    /// # Example
118    /// ```
119    /// use pgwire_replication::config::TlsConfig;
120    ///
121    /// let tls = TlsConfig::require();
122    /// assert!(tls.mode.requires_tls());
123    /// assert!(!tls.mode.verifies_certificate());
124    /// ```
125    pub fn require() -> Self {
126        Self {
127            mode: SslMode::Require,
128            ..Default::default()
129        }
130    }
131
132    /// Create a configuration with certificate chain verification.
133    ///
134    /// # Arguments
135    /// * `ca_path` - Path to CA certificate PEM file, or `None` for system roots
136    ///
137    /// # Example
138    /// ```
139    /// use pgwire_replication::config::TlsConfig;
140    ///
141    /// // Using system/Mozilla roots
142    /// let tls = TlsConfig::verify_ca(None);
143    ///
144    /// // Using custom CA
145    /// let tls = TlsConfig::verify_ca(Some("/path/to/ca.pem".into()));
146    /// ```
147    pub fn verify_ca(ca_path: Option<PathBuf>) -> Self {
148        Self {
149            mode: SslMode::VerifyCa,
150            ca_pem_path: ca_path,
151            ..Default::default()
152        }
153    }
154
155    /// Create a configuration with full verification (chain + hostname).
156    ///
157    /// **Recommended for production**.
158    ///
159    /// # Arguments
160    /// * `ca_path` - Path to CA certificate PEM file, or `None` for system roots
161    ///
162    /// # Example
163    /// ```
164    /// use pgwire_replication::config::TlsConfig;
165    ///
166    /// let tls = TlsConfig::verify_full(Some("/etc/ssl/certs/ca.pem".into()));
167    /// assert!(tls.mode.verifies_hostname());
168    /// ```
169    pub fn verify_full(ca_path: Option<PathBuf>) -> Self {
170        Self {
171            mode: SslMode::VerifyFull,
172            ca_pem_path: ca_path,
173            ..Default::default()
174        }
175    }
176
177    /// Set SNI hostname override.
178    ///
179    /// # Example
180    /// ```
181    /// use pgwire_replication::config::TlsConfig;
182    ///
183    /// let tls = TlsConfig::verify_full(None)
184    ///     .with_sni_hostname("db.example.com");
185    /// ```
186    pub fn with_sni_hostname(mut self, hostname: impl Into<String>) -> Self {
187        self.sni_hostname = Some(hostname.into());
188        self
189    }
190
191    /// Configure client certificate for mutual TLS.
192    ///
193    /// # Example
194    /// ```
195    /// use pgwire_replication::config::TlsConfig;
196    ///
197    /// let tls = TlsConfig::verify_full(Some("/ca.pem".into()))
198    ///     .with_client_cert("/client.pem", "/client.key");
199    /// ```
200    pub fn with_client_cert(
201        mut self,
202        cert_path: impl Into<PathBuf>,
203        key_path: impl Into<PathBuf>,
204    ) -> Self {
205        self.client_cert_pem_path = Some(cert_path.into());
206        self.client_key_pem_path = Some(key_path.into());
207        self
208    }
209
210    /// Returns `true` if mutual TLS (client certificate) is configured.
211    #[inline]
212    pub fn is_mtls(&self) -> bool {
213        self.client_cert_pem_path.is_some() && self.client_key_pem_path.is_some()
214    }
215}
216
217/// One or more publication names a replication slot subscribes to.
218///
219/// A single slot can stream changes from several publications at once.
220/// Construct from a single name or from a collection:
221///
222/// ```
223/// use pgwire_replication::config::Publication;
224///
225/// let one: Publication = "orders".into();
226/// let many: Publication = ["orders", "customers"].into();
227/// let dynamic: Publication = vec![String::from("a"), String::from("b")].into();
228/// ```
229///
230/// `Publication` is an immutable value fixed for the connection's lifetime: the
231/// `publication_names` set is bound once at `START_REPLICATION` and cannot be
232/// changed on a live stream. To replicate a different set, reconnect with a new
233/// [`ReplicationConfig`].
234///
235/// # Limitations
236///
237/// Names are joined into PostgreSQL's comma-separated `publication_names` list,
238/// so a name containing a comma or whitespace cannot be represented (a
239/// pre-existing PostgreSQL constraint). Single quotes in names are escaped. An
240/// empty `Publication` is invalid and the server will reject the stream.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct Publication(Vec<String>);
243
244impl Publication {
245    /// The publication names, in order.
246    #[inline]
247    pub fn names(&self) -> &[String] {
248        &self.0
249    }
250
251    /// Render the value for the `publication_names` `START_REPLICATION` option:
252    /// single quotes escaped, names comma-separated.
253    pub(crate) fn to_option_value(&self) -> String {
254        self.0
255            .iter()
256            .map(|name| name.replace('\'', "''"))
257            .collect::<Vec<_>>()
258            .join(",")
259    }
260}
261
262impl From<&str> for Publication {
263    fn from(value: &str) -> Self {
264        Self(vec![value.to_string()])
265    }
266}
267
268impl From<String> for Publication {
269    fn from(value: String) -> Self {
270        Self(vec![value])
271    }
272}
273
274impl<A: Into<String>, const N: usize> From<[A; N]> for Publication {
275    fn from(value: [A; N]) -> Self {
276        Self(value.into_iter().map(Into::into).collect())
277    }
278}
279
280impl<A: Into<String>> From<Vec<A>> for Publication {
281    fn from(value: Vec<A>) -> Self {
282        Self(value.into_iter().map(Into::into).collect())
283    }
284}
285
286impl<A: Into<String>> FromIterator<A> for Publication {
287    fn from_iter<T: IntoIterator<Item = A>>(iter: T) -> Self {
288        Self(iter.into_iter().map(Into::into).collect())
289    }
290}
291
292impl std::fmt::Display for Publication {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        write!(f, "{}", self.0.join(","))
295    }
296}
297
298/// Configuration for PostgreSQL logical replication connections.
299///
300/// # Example
301///
302/// ```
303/// use pgwire_replication::config::{ReplicationConfig, TlsConfig};
304///
305/// let config = ReplicationConfig::new(
306///     "db.example.com",
307///     "replicator",
308///     "secret",
309///     "mydb",
310///     "my_slot",
311///     "my_publication",
312/// )
313/// .with_tls(TlsConfig::verify_full(Some("/path/to/ca.pem".into())));
314/// ```
315///
316/// This struct is `#[non_exhaustive]`: construct it with [`ReplicationConfig::new`]
317/// (or [`unix`](ReplicationConfig::unix)) plus the `with_*` builder methods
318/// rather than a struct literal, so new fields can be added without breaking you.
319#[derive(Debug, Clone, PartialEq, Eq)]
320#[non_exhaustive]
321pub struct ReplicationConfig {
322    /// PostgreSQL server hostname or IP address.
323    pub host: String,
324
325    /// PostgreSQL server port (default: 5432).
326    pub port: u16,
327
328    /// PostgreSQL username with replication privileges.
329    ///
330    /// The user must have the `REPLICATION` attribute or be a superuser.
331    pub user: String,
332
333    /// Password for authentication.
334    pub password: String,
335
336    /// Database name to connect to.
337    pub database: String,
338
339    /// TLS/SSL configuration.
340    pub tls: TlsConfig,
341
342    /// Name of the replication slot to use.
343    ///
344    /// The slot must already exist and be a logical replication slot
345    /// using the `pgoutput` plugin.
346    pub slot: String,
347
348    /// Publication(s) to subscribe to.
349    ///
350    /// Each publication must exist and include the tables you want to replicate.
351    /// Accepts a single name or a collection — see [`Publication`].
352    pub publication: Publication,
353
354    /// LSN position to start replication from.
355    ///
356    /// - `Lsn(0)`: Start from slot's `confirmed_flush_lsn`
357    /// - Specific LSN: Resume from that position (must be >= slot's restart_lsn)
358    pub start_lsn: Lsn,
359
360    /// LSN reported to the server as flushed and applied until the consumer
361    /// reports more through `update_applied_lsn`.
362    ///
363    /// - `None` (default): `start_lsn`.
364    /// - `Some(lsn)`: report `lsn` instead. A consumer that resumes reading
365    ///   from a position it has not made durable sets this to its durable
366    ///   position, so the slot's `confirmed_flush_lsn` never advances past
367    ///   what the consumer could recover from after a crash.
368    pub ack_lsn: Option<Lsn>,
369
370    /// Optional LSN to stop replication at.
371    ///
372    /// When set, replication will stop once a commit with `end_lsn >= stop_at_lsn`
373    /// is received. Useful for:
374    /// - Bounded replay (e.g., point-in-time recovery)
375    /// - Testing with known data ranges
376    ///
377    /// If `None`, replication continues indefinitely (normal CDC mode).
378    pub stop_at_lsn: Option<Lsn>,
379
380    /// Interval for sending standby status updates to the server.
381    ///
382    /// Status updates inform PostgreSQL of the client's replay position,
383    /// allowing the server to release WAL segments. Too infrequent updates
384    /// may cause WAL accumulation; too frequent updates add overhead.
385    ///
386    /// Default: 10 seconds (`pg_recvlogical` defaults to 10 seconds too)
387    pub status_interval: Duration,
388
389    /// Maximum time to wait for server messages before waking up.
390    ///
391    /// Silence is normal during logical replication. When this interval elapses
392    /// with no incoming messages, the client will send a standby status update
393    /// (feedback) and continue waiting.
394    ///
395    /// A raised applied LSN does not wait for this wakeup: it is sent within
396    /// `status_interval` of `update_applied_lsn`.
397    ///
398    /// This effectively bounds how long the worker can stay blocked in a read
399    /// while idle.
400    ///
401    /// Default: 10 seconds
402    pub idle_wakeup_interval: Duration,
403
404    /// Size of the bounded event buffer between replication worker and consumer.
405    ///
406    /// Larger buffers can smooth out processing latency spikes but use more memory.
407    /// Each event is typically 100-1000 bytes depending on row size.
408    ///
409    /// Default: 8192 events
410    pub buffer_events: usize,
411
412    /// Request column values in PostgreSQL binary wire format.
413    ///
414    /// When `true`, the `binary 'true'` option is added to `START_REPLICATION`
415    /// and `pgoutput` encodes column values using each type's binary send
416    /// function instead of text output.
417    ///
418    /// This does not change anything the library parses — the worker decodes
419    /// only Begin/Commit/Message boundaries and forwards `XLogData` tuple bytes
420    /// raw — so it is the **consumer's** decoder that must handle binary values.
421    ///
422    /// Leave as `false` (the default) unless you control both ends and have
423    /// measured a real win: any column whose type lacks a binary send function
424    /// makes the walsender error and close the replication stream.
425    ///
426    /// Requires PostgreSQL 14 or newer.
427    ///
428    /// Default: `false`
429    pub binary: bool,
430
431    /// Value for the startup `options` parameter, passed through to the
432    /// server the same way `libpq`'s `options` connection parameter (or the
433    /// `PGOPTIONS` environment variable) would on an ordinary connection.
434    ///
435    /// PostgreSQL honors `options` on replication connections too, so this
436    /// is typically used to pin session GUCs (e.g.
437    /// `"-c datestyle=ISO,MDY -c intervalstyle=iso_8601"`) so that values
438    /// decoded from the replication stream are rendered in text the same
439    /// way as values read through a regular connection whose GUCs are also
440    /// pinned.
441    ///
442    /// See [`PGOPTIONS`](https://www.postgresql.org/docs/current/libpq-envars.html)
443    /// for the `-c name=value` syntax.
444    ///
445    /// Default: `None` (server defaults apply).
446    pub options: Option<String>,
447
448    /// Largest backend message **payload** accepted, in bytes.
449    ///
450    /// Measured as the message's length field minus 4, so it excludes the
451    /// 1-byte tag and the 4-byte length field: the bytes on the wire for a
452    /// message at the limit are `max_message_size + 5`.
453    ///
454    /// The worker sizes its buffer from the length the server announces, so
455    /// this bounds the memory **one message** can take. A message over the
456    /// limit is refused before any of it is buffered and ends the stream with
457    /// [`PgWireError::MessageTooLarge`](crate::error::PgWireError::MessageTooLarge).
458    /// The refused payload is never consumed, so the connection cannot be
459    /// resumed past it — the reader and connection must be discarded. The
460    /// same limit bounds each row of a gated session's
461    /// [`simple_query`](crate::ReplicationClient::simple_query).
462    ///
463    /// It bounds exactly one backend-message payload allocation. It does
464    /// **not** bound an entire transaction (whose changes span many
465    /// messages), the event channel or the decoded events buffered in
466    /// [`buffer_events`](Self::buffer_events), total process memory, or
467    /// aggregate memory across connections.
468    ///
469    /// As a matter of PostgreSQL framing, `pgoutput` writes each row change
470    /// as one `CopyData` message, so a single wide row or a large
471    /// `pg_logical_emit_message()` payload arrives as one message and must
472    /// fit under this limit. Set below that and the stream ends on the first
473    /// such row. That is a statement about framing, not a memory guarantee.
474    ///
475    /// Default: [`MAX_MESSAGE_SIZE`](crate::protocol::framing::MAX_MESSAGE_SIZE) (1 GiB)
476    pub max_message_size: usize,
477
478    /// `application_name` sent in the startup message, shown in
479    /// `pg_stat_activity` and `pg_stat_replication`. A consumer that must
480    /// tell its own walsender from other sessions sets a unique value.
481    ///
482    /// Default: `None` (`pgwire-replication`).
483    pub application_name: Option<String>,
484
485    /// Called after each successful standby status update that advanced the
486    /// position reported to the server, with how long that position waited
487    /// ([`FeedbackSent`](crate::FeedbackSent)). Without one, no timing is kept.
488    ///
489    /// Default: `None`
490    pub feedback_observer: Option<crate::FeedbackObserver>,
491}
492
493impl Default for ReplicationConfig {
494    fn default() -> Self {
495        Self {
496            host: "127.0.0.1".into(),
497            port: 5432,
498            user: "postgres".into(),
499            password: "postgres".into(),
500            database: "postgres".into(),
501            tls: TlsConfig::default(),
502            slot: "slot".into(),
503            publication: "pub".into(),
504            start_lsn: Lsn(0),
505            ack_lsn: None,
506            stop_at_lsn: None,
507            status_interval: Duration::from_secs(10),
508            idle_wakeup_interval: Duration::from_secs(10),
509            buffer_events: 8192,
510            binary: false,
511            options: None,
512            max_message_size: crate::protocol::framing::MAX_MESSAGE_SIZE,
513            application_name: None,
514            feedback_observer: None,
515        }
516    }
517}
518
519impl ReplicationConfig {
520    /// Create a new configuration with required fields.
521    ///
522    /// Other fields use defaults and can be customized with builder methods.
523    ///
524    /// # Example
525    /// ```
526    /// use pgwire_replication::config::ReplicationConfig;
527    ///
528    /// let config = ReplicationConfig::new(
529    ///     "db.example.com",
530    ///     "replicator",
531    ///     "secret",
532    ///     "mydb",
533    ///     "my_slot",
534    ///     "my_pub",
535    /// );
536    /// ```
537    pub fn new(
538        host: impl Into<String>,
539        user: impl Into<String>,
540        password: impl Into<String>,
541        database: impl Into<String>,
542        slot: impl Into<String>,
543        publication: impl Into<Publication>,
544    ) -> Self {
545        Self {
546            host: host.into(),
547            user: user.into(),
548            password: password.into(),
549            database: database.into(),
550            slot: slot.into(),
551            publication: publication.into(),
552            ..Default::default()
553        }
554    }
555
556    /// Returns `true` if `host` refers to a Unix domain socket directory.
557    ///
558    /// Following libpq convention, a host starting with `/` is treated as
559    /// the directory containing the PostgreSQL Unix socket file.
560    #[inline]
561    pub fn is_unix_socket(&self) -> bool {
562        self.host.starts_with('/')
563    }
564
565    /// Returns the full Unix socket path: `{host}/.s.PGSQL.{port}`.
566    ///
567    /// # Panics
568    ///
569    /// Panics if `host` does not start with `/` (i.e. `is_unix_socket()` is false).
570    pub fn unix_socket_path(&self) -> std::path::PathBuf {
571        assert!(
572            self.is_unix_socket(),
573            "unix_socket_path() called but host is not a socket directory: {:?}",
574            self.host
575        );
576        std::path::Path::new(&self.host).join(format!(".s.PGSQL.{}", self.port))
577    }
578
579    /// Create a configuration for connecting via Unix domain socket.
580    ///
581    /// `socket_dir` is the directory containing the PostgreSQL socket file
582    /// (e.g. `/var/run/postgresql`). The actual socket path will be
583    /// `{socket_dir}/.s.PGSQL.{port}`.
584    ///
585    /// TLS is automatically disabled for Unix socket connections.
586    ///
587    /// # Example
588    /// ```
589    /// use pgwire_replication::config::ReplicationConfig;
590    ///
591    /// let config = ReplicationConfig::unix(
592    ///     "/var/run/postgresql",
593    ///     5432,
594    ///     "replicator",
595    ///     "secret",
596    ///     "mydb",
597    ///     "my_slot",
598    ///     "my_pub",
599    /// );
600    /// assert!(config.is_unix_socket());
601    /// ```
602    pub fn unix(
603        socket_dir: impl Into<String>,
604        port: u16,
605        user: impl Into<String>,
606        password: impl Into<String>,
607        database: impl Into<String>,
608        slot: impl Into<String>,
609        publication: impl Into<Publication>,
610    ) -> Self {
611        Self {
612            host: socket_dir.into(),
613            port,
614            user: user.into(),
615            password: password.into(),
616            database: database.into(),
617            tls: TlsConfig::disabled(),
618            slot: slot.into(),
619            publication: publication.into(),
620            ..Default::default()
621        }
622    }
623
624    /// Set the server port.
625    pub fn with_port(mut self, port: u16) -> Self {
626        self.port = port;
627        self
628    }
629
630    /// Set TLS configuration.
631    pub fn with_tls(mut self, tls: TlsConfig) -> Self {
632        self.tls = tls;
633        self
634    }
635
636    /// Set the starting LSN.
637    pub fn with_start_lsn(mut self, lsn: Lsn) -> Self {
638        self.start_lsn = lsn;
639        self
640    }
641
642    /// Report `lsn` as the initially applied position instead of the start
643    /// position (see [`ReplicationConfig::ack_lsn`]).
644    pub fn with_ack_lsn(mut self, lsn: Lsn) -> Self {
645        self.ack_lsn = Some(lsn);
646        self
647    }
648
649    /// Set an optional stop LSN for bounded replay.
650    pub fn with_stop_lsn(mut self, lsn: Lsn) -> Self {
651        self.stop_at_lsn = Some(lsn);
652        self
653    }
654
655    /// Set the status update interval.
656    pub fn with_status_interval(mut self, interval: Duration) -> Self {
657        self.status_interval = interval;
658        self
659    }
660
661    /// Set the idle wakeup interval.
662    pub fn with_wakeup_interval(mut self, timeout: Duration) -> Self {
663        self.idle_wakeup_interval = timeout;
664        self
665    }
666
667    /// Set the event buffer size.
668    pub fn with_buffer_size(mut self, size: usize) -> Self {
669        self.buffer_events = size;
670        self
671    }
672
673    /// Request binary-format column values from `pgoutput` (requires PG 14+).
674    ///
675    /// See [`binary`](Self::binary) for caveats — off by default.
676    pub fn with_binary(mut self, binary: bool) -> Self {
677        self.binary = binary;
678        self
679    }
680
681    /// Set the startup `options` parameter (equivalent to `libpq`'s `options`
682    /// connection parameter / `PGOPTIONS`).
683    ///
684    /// # Example
685    /// ```
686    /// use pgwire_replication::config::ReplicationConfig;
687    ///
688    /// let config = ReplicationConfig::new("h", "u", "p", "db", "slot", "pub")
689    ///     .with_options("-c datestyle=ISO,MDY -c intervalstyle=iso_8601");
690    /// assert_eq!(
691    ///     config.options.as_deref(),
692    ///     Some("-c datestyle=ISO,MDY -c intervalstyle=iso_8601")
693    /// );
694    /// ```
695    pub fn with_options(mut self, options: impl Into<String>) -> Self {
696        self.options = Some(options.into());
697        self
698    }
699
700    /// Set the largest backend message payload accepted, in bytes.
701    ///
702    /// See [`max_message_size`](Self::max_message_size) for what the bound
703    /// covers. This can only lower the limit: a value above
704    /// [`MAX_MESSAGE_SIZE`](crate::protocol::framing::MAX_MESSAGE_SIZE) is
705    /// capped to it.
706    ///
707    /// # Example
708    /// ```
709    /// use pgwire_replication::config::ReplicationConfig;
710    ///
711    /// let config = ReplicationConfig::new("localhost", "u", "p", "db", "slot", "pub")
712    ///     .with_max_message_size(64 * 1024 * 1024);
713    /// assert_eq!(config.max_message_size, 64 * 1024 * 1024);
714    /// ```
715    pub fn with_max_message_size(mut self, bytes: usize) -> Self {
716        self.max_message_size = bytes.min(crate::protocol::framing::MAX_MESSAGE_SIZE);
717        self
718    }
719
720    /// Set the startup `application_name` (see
721    /// [`ReplicationConfig::application_name`]).
722    pub fn with_application_name(mut self, name: impl Into<String>) -> Self {
723        self.application_name = Some(name.into());
724        self
725    }
726
727    /// Observe successful standby status updates that advanced the reported
728    /// position (see [`feedback_observer`](Self::feedback_observer)).
729    pub fn with_feedback_observer(mut self, observer: crate::FeedbackObserver) -> Self {
730        self.feedback_observer = Some(observer);
731        self
732    }
733
734    /// Returns the connection string for display (password masked).
735    ///
736    /// Useful for logging without exposing credentials.
737    pub fn display_connection(&self) -> String {
738        if self.is_unix_socket() {
739            format!(
740                "postgresql://{}:***@[{}]:{}/{}",
741                self.user,
742                self.unix_socket_path().display(),
743                self.port,
744                self.database
745            )
746        } else {
747            format!(
748                "postgresql://{}:***@{}:{}/{}",
749                self.user, self.host, self.port, self.database
750            )
751        }
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn publication_from_single_str() {
761        let p: Publication = "orders".into();
762        assert_eq!(p.names(), ["orders"]);
763        assert_eq!(p.to_option_value(), "orders");
764    }
765
766    #[test]
767    fn publication_from_array_is_comma_joined() {
768        let p: Publication = ["orders", "customers"].into();
769        assert_eq!(p.names(), ["orders", "customers"]);
770        assert_eq!(p.to_option_value(), "orders,customers");
771    }
772
773    #[test]
774    fn publication_from_vec_and_from_iter() {
775        let from_vec: Publication = vec![String::from("a"), String::from("b")].into();
776        assert_eq!(from_vec.to_option_value(), "a,b");
777
778        let collected: Publication = ["x", "y", "z"].into_iter().collect();
779        assert_eq!(collected.to_option_value(), "x,y,z");
780    }
781
782    #[test]
783    fn publication_escapes_single_quotes_per_name() {
784        let p: Publication = ["a'b", "c"].into();
785        // Each single quote is doubled; names remain comma-separated.
786        assert_eq!(p.to_option_value(), "a''b,c");
787    }
788
789    #[test]
790    fn publication_display_is_plain_join_without_escaping() {
791        let p: Publication = ["a'b", "c"].into();
792        assert_eq!(p.to_string(), "a'b,c");
793    }
794
795    #[test]
796    fn sslmode_verification_predicates() {
797        assert!(SslMode::VerifyCa.verifies_certificate());
798        assert!(SslMode::VerifyFull.verifies_certificate());
799        assert!(!SslMode::Require.verifies_certificate());
800
801        assert!(SslMode::VerifyFull.verifies_hostname());
802        assert!(!SslMode::VerifyCa.verifies_hostname());
803        assert!(!SslMode::Require.verifies_hostname());
804    }
805
806    #[test]
807    fn verify_ca_sets_mode_and_ca_path() {
808        let tls = TlsConfig::verify_ca(Some("/ca.pem".into()));
809        assert_eq!(tls.mode, SslMode::VerifyCa);
810        assert_eq!(tls.ca_pem_path, Some("/ca.pem".into()));
811    }
812
813    #[test]
814    fn is_mtls_requires_both_cert_and_key() {
815        let both = TlsConfig::verify_full(None).with_client_cert("/c.pem", "/k.pem");
816        assert!(both.is_mtls());
817
818        let neither = TlsConfig::verify_full(None);
819        assert!(!neither.is_mtls());
820
821        let cert_only = TlsConfig {
822            client_key_pem_path: None,
823            ..TlsConfig::verify_full(None).with_client_cert("/c.pem", "/k.pem")
824        };
825        assert!(!cert_only.is_mtls());
826    }
827
828    #[test]
829    fn is_unix_socket_and_path() {
830        let tcp = ReplicationConfig::new("127.0.0.1", "u", "p", "db", "s", "pub");
831        assert!(!tcp.is_unix_socket());
832
833        let unix = ReplicationConfig::unix("/var/run/postgresql", 5432, "u", "p", "db", "s", "pub");
834        assert!(unix.is_unix_socket());
835        assert_eq!(
836            unix.unix_socket_path(),
837            std::path::PathBuf::from("/var/run/postgresql/.s.PGSQL.5432")
838        );
839    }
840
841    #[test]
842    fn display_connection_masks_password_and_shows_target() {
843        let cfg = ReplicationConfig::new("db.example.com", "alice", "secret", "mydb", "s", "pub")
844            .with_port(6432);
845        let shown = cfg.display_connection();
846        assert!(shown.contains("alice"));
847        assert!(shown.contains("db.example.com"));
848        assert!(shown.contains("6432"));
849        assert!(shown.contains("mydb"));
850        assert!(shown.contains("***"));
851        assert!(!shown.contains("secret"));
852    }
853
854    #[test]
855    fn binary_defaults_off_and_builder_sets_it() {
856        assert!(!ReplicationConfig::default().binary);
857        let cfg = ReplicationConfig::new("h", "u", "p", "db", "slot", "pub").with_binary(true);
858        assert!(cfg.binary);
859    }
860
861    #[test]
862    fn max_message_size_defaults_to_the_protocol_limit_and_builder_sets_it() {
863        assert_eq!(
864            ReplicationConfig::default().max_message_size,
865            crate::protocol::framing::MAX_MESSAGE_SIZE
866        );
867        let cfg =
868            ReplicationConfig::new("h", "u", "p", "db", "slot", "pub").with_max_message_size(1024);
869        assert_eq!(cfg.max_message_size, 1024);
870    }
871
872    #[test]
873    fn max_message_size_cannot_be_raised_above_the_protocol_limit() {
874        let cfg = ReplicationConfig::new("h", "u", "p", "db", "slot", "pub")
875            .with_max_message_size(usize::MAX);
876        assert_eq!(
877            cfg.max_message_size,
878            crate::protocol::framing::MAX_MESSAGE_SIZE
879        );
880    }
881
882    #[test]
883    fn options_defaults_none_and_builder_sets_it() {
884        assert_eq!(ReplicationConfig::default().options, None);
885        let cfg = ReplicationConfig::new("h", "u", "p", "db", "slot", "pub")
886            .with_options("-c datestyle=ISO,MDY");
887        assert_eq!(cfg.options.as_deref(), Some("-c datestyle=ISO,MDY"));
888    }
889
890    #[test]
891    fn new_accepts_single_and_multiple_publications() {
892        let single = ReplicationConfig::new("h", "u", "p", "db", "slot", "solo");
893        assert_eq!(single.publication.to_option_value(), "solo");
894
895        let multi = ReplicationConfig::new("h", "u", "p", "db", "slot", ["one", "two"]);
896        assert_eq!(multi.publication.to_option_value(), "one,two");
897    }
898}