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}