use std::path::PathBuf;
use std::time::Duration;
use crate::lsn::Lsn;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SslMode {
#[default]
Disable,
Prefer,
Require,
VerifyCa,
VerifyFull,
}
impl SslMode {
#[inline]
pub fn requires_tls(&self) -> bool {
!matches!(self, SslMode::Disable | SslMode::Prefer)
}
#[inline]
pub fn verifies_certificate(&self) -> bool {
matches!(self, SslMode::VerifyCa | SslMode::VerifyFull)
}
#[inline]
pub fn verifies_hostname(&self) -> bool {
matches!(self, SslMode::VerifyFull)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TlsConfig {
pub mode: SslMode,
pub ca_pem_path: Option<PathBuf>,
pub sni_hostname: Option<String>,
pub client_cert_pem_path: Option<PathBuf>,
pub client_key_pem_path: Option<PathBuf>,
}
impl TlsConfig {
pub fn disabled() -> Self {
Self::default()
}
pub fn require() -> Self {
Self {
mode: SslMode::Require,
..Default::default()
}
}
pub fn verify_ca(ca_path: Option<PathBuf>) -> Self {
Self {
mode: SslMode::VerifyCa,
ca_pem_path: ca_path,
..Default::default()
}
}
pub fn verify_full(ca_path: Option<PathBuf>) -> Self {
Self {
mode: SslMode::VerifyFull,
ca_pem_path: ca_path,
..Default::default()
}
}
pub fn with_sni_hostname(mut self, hostname: impl Into<String>) -> Self {
self.sni_hostname = Some(hostname.into());
self
}
pub fn with_client_cert(
mut self,
cert_path: impl Into<PathBuf>,
key_path: impl Into<PathBuf>,
) -> Self {
self.client_cert_pem_path = Some(cert_path.into());
self.client_key_pem_path = Some(key_path.into());
self
}
#[inline]
pub fn is_mtls(&self) -> bool {
self.client_cert_pem_path.is_some() && self.client_key_pem_path.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Publication(Vec<String>);
impl Publication {
#[inline]
pub fn names(&self) -> &[String] {
&self.0
}
pub(crate) fn to_option_value(&self) -> String {
self.0
.iter()
.map(|name| name.replace('\'', "''"))
.collect::<Vec<_>>()
.join(",")
}
}
impl From<&str> for Publication {
fn from(value: &str) -> Self {
Self(vec![value.to_string()])
}
}
impl From<String> for Publication {
fn from(value: String) -> Self {
Self(vec![value])
}
}
impl<A: Into<String>, const N: usize> From<[A; N]> for Publication {
fn from(value: [A; N]) -> Self {
Self(value.into_iter().map(Into::into).collect())
}
}
impl<A: Into<String>> From<Vec<A>> for Publication {
fn from(value: Vec<A>) -> Self {
Self(value.into_iter().map(Into::into).collect())
}
}
impl<A: Into<String>> FromIterator<A> for Publication {
fn from_iter<T: IntoIterator<Item = A>>(iter: T) -> Self {
Self(iter.into_iter().map(Into::into).collect())
}
}
impl std::fmt::Display for Publication {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.join(","))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ReplicationConfig {
pub host: String,
pub port: u16,
pub user: String,
pub password: String,
pub database: String,
pub tls: TlsConfig,
pub slot: String,
pub publication: Publication,
pub start_lsn: Lsn,
pub stop_at_lsn: Option<Lsn>,
pub status_interval: Duration,
pub idle_wakeup_interval: Duration,
pub buffer_events: usize,
pub binary: bool,
}
impl Default for ReplicationConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".into(),
port: 5432,
user: "postgres".into(),
password: "postgres".into(),
database: "postgres".into(),
tls: TlsConfig::default(),
slot: "slot".into(),
publication: "pub".into(),
start_lsn: Lsn(0),
stop_at_lsn: None,
status_interval: Duration::from_secs(10),
idle_wakeup_interval: Duration::from_secs(10),
buffer_events: 8192,
binary: false,
}
}
}
impl ReplicationConfig {
pub fn new(
host: impl Into<String>,
user: impl Into<String>,
password: impl Into<String>,
database: impl Into<String>,
slot: impl Into<String>,
publication: impl Into<Publication>,
) -> Self {
Self {
host: host.into(),
user: user.into(),
password: password.into(),
database: database.into(),
slot: slot.into(),
publication: publication.into(),
..Default::default()
}
}
#[inline]
pub fn is_unix_socket(&self) -> bool {
self.host.starts_with('/')
}
pub fn unix_socket_path(&self) -> std::path::PathBuf {
assert!(
self.is_unix_socket(),
"unix_socket_path() called but host is not a socket directory: {:?}",
self.host
);
std::path::Path::new(&self.host).join(format!(".s.PGSQL.{}", self.port))
}
pub fn unix(
socket_dir: impl Into<String>,
port: u16,
user: impl Into<String>,
password: impl Into<String>,
database: impl Into<String>,
slot: impl Into<String>,
publication: impl Into<Publication>,
) -> Self {
Self {
host: socket_dir.into(),
port,
user: user.into(),
password: password.into(),
database: database.into(),
tls: TlsConfig::disabled(),
slot: slot.into(),
publication: publication.into(),
..Default::default()
}
}
pub fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn with_tls(mut self, tls: TlsConfig) -> Self {
self.tls = tls;
self
}
pub fn with_start_lsn(mut self, lsn: Lsn) -> Self {
self.start_lsn = lsn;
self
}
pub fn with_stop_lsn(mut self, lsn: Lsn) -> Self {
self.stop_at_lsn = Some(lsn);
self
}
pub fn with_status_interval(mut self, interval: Duration) -> Self {
self.status_interval = interval;
self
}
pub fn with_wakeup_interval(mut self, timeout: Duration) -> Self {
self.idle_wakeup_interval = timeout;
self
}
pub fn with_buffer_size(mut self, size: usize) -> Self {
self.buffer_events = size;
self
}
pub fn with_binary(mut self, binary: bool) -> Self {
self.binary = binary;
self
}
pub fn display_connection(&self) -> String {
if self.is_unix_socket() {
format!(
"postgresql://{}:***@[{}]:{}/{}",
self.user,
self.unix_socket_path().display(),
self.port,
self.database
)
} else {
format!(
"postgresql://{}:***@{}:{}/{}",
self.user, self.host, self.port, self.database
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn publication_from_single_str() {
let p: Publication = "orders".into();
assert_eq!(p.names(), ["orders"]);
assert_eq!(p.to_option_value(), "orders");
}
#[test]
fn publication_from_array_is_comma_joined() {
let p: Publication = ["orders", "customers"].into();
assert_eq!(p.names(), ["orders", "customers"]);
assert_eq!(p.to_option_value(), "orders,customers");
}
#[test]
fn publication_from_vec_and_from_iter() {
let from_vec: Publication = vec![String::from("a"), String::from("b")].into();
assert_eq!(from_vec.to_option_value(), "a,b");
let collected: Publication = ["x", "y", "z"].into_iter().collect();
assert_eq!(collected.to_option_value(), "x,y,z");
}
#[test]
fn publication_escapes_single_quotes_per_name() {
let p: Publication = ["a'b", "c"].into();
assert_eq!(p.to_option_value(), "a''b,c");
}
#[test]
fn publication_display_is_plain_join_without_escaping() {
let p: Publication = ["a'b", "c"].into();
assert_eq!(p.to_string(), "a'b,c");
}
#[test]
fn sslmode_verification_predicates() {
assert!(SslMode::VerifyCa.verifies_certificate());
assert!(SslMode::VerifyFull.verifies_certificate());
assert!(!SslMode::Require.verifies_certificate());
assert!(SslMode::VerifyFull.verifies_hostname());
assert!(!SslMode::VerifyCa.verifies_hostname());
assert!(!SslMode::Require.verifies_hostname());
}
#[test]
fn verify_ca_sets_mode_and_ca_path() {
let tls = TlsConfig::verify_ca(Some("/ca.pem".into()));
assert_eq!(tls.mode, SslMode::VerifyCa);
assert_eq!(tls.ca_pem_path, Some("/ca.pem".into()));
}
#[test]
fn is_mtls_requires_both_cert_and_key() {
let both = TlsConfig::verify_full(None).with_client_cert("/c.pem", "/k.pem");
assert!(both.is_mtls());
let neither = TlsConfig::verify_full(None);
assert!(!neither.is_mtls());
let cert_only = TlsConfig {
client_key_pem_path: None,
..TlsConfig::verify_full(None).with_client_cert("/c.pem", "/k.pem")
};
assert!(!cert_only.is_mtls());
}
#[test]
fn is_unix_socket_and_path() {
let tcp = ReplicationConfig::new("127.0.0.1", "u", "p", "db", "s", "pub");
assert!(!tcp.is_unix_socket());
let unix = ReplicationConfig::unix("/var/run/postgresql", 5432, "u", "p", "db", "s", "pub");
assert!(unix.is_unix_socket());
assert_eq!(
unix.unix_socket_path(),
std::path::PathBuf::from("/var/run/postgresql/.s.PGSQL.5432")
);
}
#[test]
fn display_connection_masks_password_and_shows_target() {
let cfg = ReplicationConfig::new("db.example.com", "alice", "secret", "mydb", "s", "pub")
.with_port(6432);
let shown = cfg.display_connection();
assert!(shown.contains("alice"));
assert!(shown.contains("db.example.com"));
assert!(shown.contains("6432"));
assert!(shown.contains("mydb"));
assert!(shown.contains("***"));
assert!(!shown.contains("secret"));
}
#[test]
fn binary_defaults_off_and_builder_sets_it() {
assert!(!ReplicationConfig::default().binary);
let cfg = ReplicationConfig::new("h", "u", "p", "db", "slot", "pub").with_binary(true);
assert!(cfg.binary);
}
#[test]
fn new_accepts_single_and_multiple_publications() {
let single = ReplicationConfig::new("h", "u", "p", "db", "slot", "solo");
assert_eq!(single.publication.to_option_value(), "solo");
let multi = ReplicationConfig::new("h", "u", "p", "db", "slot", ["one", "two"]);
assert_eq!(multi.publication.to_option_value(), "one,two");
}
}