1use std::path::PathBuf;
7
8use crate::config::ConnectorConfig;
9use crate::error::ConnectorError;
10use crate::postgres::SslMode;
11
12const REMOVED_CONFIG_KEYS: &[&str] = &[
13 "backpressure.high.watermark",
14 "keepalive.interval.ms",
15 "max.buffered.events",
16 "max.poll.records",
17 "poll.timeout.ms",
18 "snapshot.mode",
19 "start.lsn",
20 "wal.sender.timeout.ms",
21];
22
23const DEFAULT_BUFFERED_BYTES: usize = 256 * 1024 * 1024;
24const MIN_BUFFERED_BYTES: usize = 1024 * 1024;
25const MAX_BUFFERED_BYTES: usize = 4 * 1024 * 1024 * 1024;
26const WORKING_SET_STAGES: usize = 3;
27
28const ALLOWED_CONFIG_KEYS: &[&str] = &[
29 "_arrow_schema",
30 "database",
31 "host",
32 "laminar.source.name",
33 "max.buffered.bytes",
34 "password",
35 "port",
36 "publication",
37 "slot.name",
38 "ssl.ca.cert.path",
39 "ssl.mode",
40 "table.exclude",
41 "table.include",
42 "username",
43];
44
45#[derive(Debug, Clone)]
47pub struct PostgresCdcConfig {
48 pub host: String,
51
52 pub port: u16,
54
55 pub database: String,
57
58 pub username: String,
60
61 pub password: Option<String>,
63
64 pub ssl_mode: SslMode,
66
67 pub ssl_ca_cert_path: Option<PathBuf>,
69
70 pub slot_name: String,
73
74 pub publication: String,
76
77 pub table_include: Vec<String>,
80
81 pub table_exclude: Vec<String>,
83
84 pub max_buffered_bytes: usize,
86}
87
88impl Default for PostgresCdcConfig {
89 fn default() -> Self {
90 Self {
91 host: "localhost".to_string(),
92 port: 5432,
93 database: "postgres".to_string(),
94 username: "postgres".to_string(),
95 password: None,
96 ssl_mode: SslMode::VerifyFull,
97 ssl_ca_cert_path: None,
98 slot_name: "laminar_slot".to_string(),
99 publication: "laminar_pub".to_string(),
100 table_include: Vec::new(),
101 table_exclude: Vec::new(),
102 max_buffered_bytes: DEFAULT_BUFFERED_BYTES,
103 }
104 }
105}
106
107impl PostgresCdcConfig {
108 #[must_use]
110 pub(super) fn decoded_high_watermark_bytes(&self) -> usize {
111 self.decoded_event_bytes()
112 .saturating_sub(self.decoded_event_bytes() / 5)
113 }
114
115 #[must_use]
117 pub(crate) fn raw_wal_bytes(&self) -> usize {
118 self.max_buffered_bytes / WORKING_SET_STAGES
119 }
120
121 #[must_use]
123 pub(crate) fn decoded_event_bytes(&self) -> usize {
124 self.max_buffered_bytes / WORKING_SET_STAGES
125 }
126
127 #[must_use]
129 pub(crate) fn arrow_build_bytes(&self) -> usize {
130 self.max_buffered_bytes
131 .saturating_sub(self.raw_wal_bytes())
132 .saturating_sub(self.decoded_event_bytes())
133 }
134
135 pub(crate) fn normalize_table_filters(&mut self) {
136 normalize_table_list(&mut self.table_include);
137 normalize_table_list(&mut self.table_exclude);
138 }
139
140 #[must_use]
142 pub fn new(host: &str, database: &str, slot_name: &str, publication: &str) -> Self {
143 Self {
144 host: host.to_string(),
145 database: database.to_string(),
146 slot_name: slot_name.to_string(),
147 publication: publication.to_string(),
148 ..Self::default()
149 }
150 }
151
152 pub(super) fn control_connection_config(
158 &self,
159 ) -> Result<tokio_postgres::Config, ConnectorError> {
160 self.validate()?;
161
162 let mut config = tokio_postgres::Config::new();
163 config
164 .host(&self.host)
165 .port(self.port)
166 .dbname(&self.database)
167 .user(&self.username)
168 .ssl_mode(match self.ssl_mode {
169 SslMode::Disable => tokio_postgres::config::SslMode::Disable,
170 SslMode::VerifyFull => tokio_postgres::config::SslMode::Require,
171 })
172 .connect_timeout(super::postgres_io::CONNECT_TIMEOUT);
173 if let Some(password) = &self.password {
174 config.password(password);
175 }
176 Ok(config)
177 }
178
179 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
186 Self::reject_removed_keys(config)?;
187 config.reject_unknown_properties(ALLOWED_CONFIG_KEYS, "PostgreSQL CDC")?;
188
189 let mut cfg = Self {
190 host: config.require("host")?.to_string(),
191 database: config.require("database")?.to_string(),
192 slot_name: config.require("slot.name")?.to_string(),
193 publication: config.require("publication")?.to_string(),
194 ssl_mode: config
195 .get_parsed::<SslMode>("ssl.mode")?
196 .unwrap_or_default(),
197 ..Self::default()
198 };
199
200 if let Some(port) = config.get("port") {
201 cfg.port = crate::config::parse_port(port)?;
202 }
203 if let Some(user) = config.get("username") {
204 cfg.username = user.to_string();
205 }
206 cfg.password = config.get("password").map(String::from);
207 cfg.ssl_ca_cert_path = config.get("ssl.ca.cert.path").map(PathBuf::from);
208
209 if let Some(tables) = config.get("table.include") {
210 cfg.table_include = tables.split(',').map(str::to_string).collect();
211 }
212 if let Some(tables) = config.get("table.exclude") {
213 cfg.table_exclude = tables.split(',').map(str::to_string).collect();
214 }
215 if let Some(max) = config.get_parsed::<usize>("max.buffered.bytes")? {
216 cfg.max_buffered_bytes = max;
217 }
218 cfg.normalize_table_filters();
219 cfg.validate()?;
220 Ok(cfg)
221 }
222
223 fn reject_removed_keys(config: &ConnectorConfig) -> Result<(), ConnectorError> {
224 if let Some(key) = REMOVED_CONFIG_KEYS
225 .iter()
226 .find(|key| config.get(key).is_some())
227 {
228 let reason = match *key {
229 "start.lsn" => {
230 "recovery cursors are owned by a validated engine checkpoint or the durable slot"
231 }
232 "max.buffered.events" => {
233 "resource ownership is bounded by max.buffered.bytes instead"
234 }
235 _ => "the connector did not execute it",
236 };
237 return Err(ConnectorError::ConfigurationError(format!(
238 "PostgreSQL CDC property '{key}' is not supported: {reason}"
239 )));
240 }
241 Ok(())
242 }
243
244 pub fn validate(&self) -> Result<(), ConnectorError> {
250 crate::config::require_non_empty(&self.host, "host")?;
251 crate::config::require_non_empty(&self.database, "database")?;
252 crate::config::require_non_empty(&self.username, "username")?;
253 crate::config::require_non_empty(&self.slot_name, "slot.name")?;
254 crate::config::require_non_empty(&self.publication, "publication")?;
255 for (value, label) in [
256 (&self.host, "host"),
257 (&self.database, "database"),
258 (&self.username, "username"),
259 (&self.slot_name, "slot.name"),
260 (&self.publication, "publication"),
261 ] {
262 if value.contains('\0') {
263 return Err(ConnectorError::ConfigurationError(format!(
264 "{label} must not contain NUL"
265 )));
266 }
267 }
268 if self.slot_name.len() > 63
269 || !self
270 .slot_name
271 .bytes()
272 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
273 {
274 return Err(ConnectorError::ConfigurationError(
275 "slot.name must be at most 63 bytes and contain only lower-case ASCII letters, digits, or underscore"
276 .into(),
277 ));
278 }
279 if self.publication.len() > 63
280 || !self
281 .publication
282 .bytes()
283 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
284 || self.publication.as_bytes()[0].is_ascii_digit()
285 {
286 return Err(ConnectorError::ConfigurationError(
287 "publication must start with a lower-case ASCII letter or underscore and contain only lower-case ASCII letters, digits, or underscore (63 bytes maximum)"
288 .into(),
289 ));
290 }
291 if self
292 .password
293 .as_deref()
294 .is_some_and(|value| value.contains('\0'))
295 {
296 return Err(ConnectorError::ConfigurationError(
297 "password must not contain NUL".into(),
298 ));
299 }
300 if self.port == 0 {
301 return Err(ConnectorError::ConfigurationError(
302 "port must be > 0".to_string(),
303 ));
304 }
305 if !(MIN_BUFFERED_BYTES..=MAX_BUFFERED_BYTES).contains(&self.max_buffered_bytes) {
306 return Err(ConnectorError::ConfigurationError(format!(
307 "max.buffered.bytes must be between {MIN_BUFFERED_BYTES} and {MAX_BUFFERED_BYTES}"
308 )));
309 }
310 if self
311 .ssl_ca_cert_path
312 .as_ref()
313 .is_some_and(|path| path.as_os_str().is_empty())
314 {
315 return Err(ConnectorError::ConfigurationError(
316 "ssl.ca.cert.path must not be empty".to_string(),
317 ));
318 }
319 if self.ssl_mode == SslMode::Disable && self.ssl_ca_cert_path.is_some() {
320 return Err(ConnectorError::ConfigurationError(
321 "ssl.ca.cert.path requires ssl.mode=verify-full".to_string(),
322 ));
323 }
324 for (label, tables) in [
325 ("table.include", &self.table_include),
326 ("table.exclude", &self.table_exclude),
327 ] {
328 for table in tables {
329 let Some((schema, relation)) = table.split_once('.') else {
330 return Err(ConnectorError::ConfigurationError(format!(
331 "{label} entry '{table}' must be schema-qualified as schema.table"
332 )));
333 };
334 if table.trim() != table || schema.is_empty() || relation.is_empty() {
335 return Err(ConnectorError::ConfigurationError(format!(
336 "{label} entry '{table}' must contain nonempty schema and table names"
337 )));
338 }
339 }
340 }
341 Ok(())
342 }
343
344 #[must_use]
346 pub(crate) fn should_include_table(&self, table: &str) -> bool {
347 debug_assert!(self.table_include.is_sorted());
348 debug_assert!(self.table_exclude.is_sorted());
349 if self
350 .table_exclude
351 .binary_search_by(|candidate| candidate.as_str().cmp(table))
352 .is_ok()
353 {
354 return false;
355 }
356 if self.table_include.is_empty() {
357 return true;
358 }
359 self.table_include
360 .binary_search_by(|candidate| candidate.as_str().cmp(table))
361 .is_ok()
362 }
363}
364
365fn normalize_table_list(tables: &mut Vec<String>) {
366 for table in tables.iter_mut() {
367 *table = table.trim().to_string();
368 }
369 tables.retain(|table| !table.is_empty());
370 tables.sort_unstable();
371 tables.dedup();
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn connector_config() -> ConnectorConfig {
379 let mut config = ConnectorConfig::new("postgres-cdc");
380 config.set("host", "localhost");
381 config.set("database", "db");
382 config.set("slot.name", "s");
383 config.set("publication", "p");
384 config.set("ssl.mode", "disable");
385 config
386 }
387
388 #[test]
389 fn test_default_config() {
390 let cfg = PostgresCdcConfig::default();
391 assert_eq!(cfg.host, "localhost");
392 assert_eq!(cfg.port, 5432);
393 assert_eq!(cfg.database, "postgres");
394 assert_eq!(cfg.slot_name, "laminar_slot");
395 assert_eq!(cfg.publication, "laminar_pub");
396 assert_eq!(cfg.ssl_mode, SslMode::VerifyFull);
397 assert!(cfg.validate().is_ok());
398 }
399
400 #[test]
401 fn replication_identity_rejects_invalid_slot_and_nul() {
402 let mut cfg = PostgresCdcConfig::default();
403 cfg.slot_name = "Mixed-Case".into();
404 assert!(cfg
405 .validate()
406 .unwrap_err()
407 .to_string()
408 .contains("slot.name"));
409
410 cfg.slot_name = "valid_slot".into();
411 cfg.publication = "bad\0publication".into();
412 assert!(cfg.validate().unwrap_err().to_string().contains("NUL"));
413
414 cfg.publication = "Mixed-Publication".into();
415 assert!(cfg
416 .validate()
417 .unwrap_err()
418 .to_string()
419 .contains("publication"));
420 }
421
422 #[test]
423 fn test_new_config() {
424 let cfg = PostgresCdcConfig::new("db.example.com", "mydb", "my_slot", "my_pub");
425 assert_eq!(cfg.host, "db.example.com");
426 assert_eq!(cfg.database, "mydb");
427 assert_eq!(cfg.slot_name, "my_slot");
428 assert_eq!(cfg.publication, "my_pub");
429 assert_eq!(cfg.ssl_mode, SslMode::VerifyFull);
430 }
431
432 #[test]
433 fn typed_control_config_preserves_adversarial_values() {
434 let mut cfg =
435 PostgresCdcConfig::new(" db\\host' ", " db name'\\ ", "valid_slot", "publication");
436 cfg.username = " user name'\\ ".into();
437 cfg.password = Some(" password with 'quotes' and \\slashes\\ ".into());
438
439 let control = cfg.control_connection_config().unwrap();
440 assert_eq!(
441 control.get_hosts(),
442 &[tokio_postgres::config::Host::Tcp(" db\\host' ".into())]
443 );
444 assert_eq!(control.get_ports(), &[5432]);
445 assert_eq!(control.get_dbname(), Some(" db name'\\ "));
446 assert_eq!(control.get_user(), Some(" user name'\\ "));
447 assert_eq!(
448 control.get_password(),
449 Some(" password with 'quotes' and \\slashes\\ ".as_bytes())
450 );
451 assert_eq!(
452 control.get_ssl_mode(),
453 tokio_postgres::config::SslMode::Require
454 );
455 assert_eq!(
456 control.get_connect_timeout().copied(),
457 Some(crate::postgres::cdc::postgres_io::CONNECT_TIMEOUT)
458 );
459 }
460
461 #[test]
462 fn typed_control_config_maps_disabled_tls_exactly() {
463 let mut cfg = PostgresCdcConfig::default();
464 cfg.ssl_mode = SslMode::Disable;
465 assert_eq!(
466 cfg.control_connection_config().unwrap().get_ssl_mode(),
467 tokio_postgres::config::SslMode::Disable
468 );
469 }
470
471 #[test]
472 fn test_from_connector_config() {
473 let mut config = ConnectorConfig::new("postgres-cdc");
474 config.set("host", "pg.local");
475 config.set("database", "testdb");
476 config.set("slot.name", "test_slot");
477 config.set("publication", "test_pub");
478 config.set("ssl.mode", "disable");
479 config.set("port", "5433");
480 config.set("max.buffered.bytes", "67108864");
481
482 let cfg = PostgresCdcConfig::from_config(&config).unwrap();
483 assert_eq!(cfg.host, "pg.local");
484 assert_eq!(cfg.port, 5433);
485 assert_eq!(cfg.database, "testdb");
486 assert_eq!(cfg.max_buffered_bytes, 64 * 1024 * 1024);
487 }
488
489 #[test]
490 fn total_byte_budget_is_partitioned_without_loss() {
491 let mut cfg = PostgresCdcConfig::default();
492 cfg.max_buffered_bytes = MIN_BUFFERED_BYTES;
493 assert_eq!(
494 cfg.decoded_high_watermark_bytes(),
495 cfg.decoded_event_bytes() - cfg.decoded_event_bytes() / 5
496 );
497 assert_eq!(
498 cfg.raw_wal_bytes() + cfg.decoded_event_bytes() + cfg.arrow_build_bytes(),
499 MIN_BUFFERED_BYTES
500 );
501 }
502
503 #[test]
504 fn total_byte_budget_rejects_values_outside_the_operational_range() {
505 for bytes in [MIN_BUFFERED_BYTES - 1, MAX_BUFFERED_BYTES + 1] {
506 let mut cfg = PostgresCdcConfig::default();
507 cfg.max_buffered_bytes = bytes;
508 assert!(cfg.validate().is_err(), "{bytes}");
509 }
510 }
511
512 #[test]
513 fn test_from_config_missing_required() {
514 let config = ConnectorConfig::new("postgres-cdc");
515 assert!(PostgresCdcConfig::from_config(&config).is_err());
516 }
517
518 #[test]
519 fn test_from_config_invalid_port() {
520 let mut config = connector_config();
521 config.set("port", "not_a_number");
522 assert!(PostgresCdcConfig::from_config(&config).is_err());
523 }
524
525 #[test]
526 fn omitted_ssl_mode_uses_verified_tls() {
527 let mut config = ConnectorConfig::new("postgres-cdc");
528 config.set("host", "localhost");
529 config.set("database", "db");
530 config.set("slot.name", "s");
531 config.set("publication", "p");
532 let config = PostgresCdcConfig::from_config(&config).unwrap();
533 assert_eq!(config.ssl_mode, SslMode::VerifyFull);
534 }
535
536 #[test]
537 fn unknown_properties_are_rejected_deterministically() {
538 let mut config = connector_config();
539 config.set("z.invalid", "1");
540 config.set("a.invalid", "2");
541 let error = PostgresCdcConfig::from_config(&config).unwrap_err();
542 assert!(error.to_string().contains("a.invalid"), "{error}");
543 }
544
545 #[test]
546 fn engine_metadata_properties_are_admitted() {
547 let mut config = connector_config();
548 config.set("laminar.source.name", "orders");
549 config.set("_arrow_schema", "engine-owned");
550 PostgresCdcConfig::from_config(&config).unwrap();
551 }
552
553 #[test]
554 fn test_validate_empty_host() {
555 let mut cfg = PostgresCdcConfig::default();
556 cfg.host = String::new();
557 assert!(cfg.validate().is_err());
558 }
559
560 #[test]
561 fn removed_properties_are_rejected_explicitly() {
562 for key in REMOVED_CONFIG_KEYS {
563 let mut config = ConnectorConfig::new("postgres-cdc");
564 config.set("host", "localhost");
565 config.set("database", "db");
566 config.set("slot.name", "s");
567 config.set("publication", "p");
568 config.set(*key, "removed-value");
569 let error = PostgresCdcConfig::from_config(&config).unwrap_err();
570 assert!(error.to_string().contains(key));
571 }
572 }
573
574 #[test]
575 fn test_table_filtering() {
576 let mut cfg = PostgresCdcConfig::default();
577 assert!(cfg.should_include_table("public.users"));
579
580 cfg.table_include = vec!["public.users".to_string(), "public.orders".to_string()];
582 cfg.normalize_table_filters();
583 assert!(cfg.should_include_table("public.users"));
584 assert!(!cfg.should_include_table("public.logs"));
585
586 cfg.table_exclude = vec!["public.users".to_string()];
588 assert!(!cfg.should_include_table("public.users"));
589 }
590
591 #[test]
592 fn manual_start_lsn_is_rejected() {
593 let mut config = connector_config();
594 config.set("start.lsn", "0/1234ABCD");
595 let error = PostgresCdcConfig::from_config(&config).unwrap_err();
596 assert!(error.to_string().contains("start.lsn"));
597 }
598
599 #[test]
600 fn test_from_config_table_include() {
601 let mut config = connector_config();
602 config.set("table.include", "public.users, public.orders");
603
604 let cfg = PostgresCdcConfig::from_config(&config).unwrap();
605 assert_eq!(cfg.table_include, vec!["public.orders", "public.users"]);
606 }
607
608 #[test]
609 fn table_filters_are_trimmed_nonempty_sorted_and_deduplicated_once() {
610 let mut config = connector_config();
611 config.set(
612 "table.include",
613 " public.users,public.orders,, public.users, ",
614 );
615 config.set(
616 "table.exclude",
617 " public.audit, ,public.archive,public.audit ",
618 );
619
620 let cfg = PostgresCdcConfig::from_config(&config).unwrap();
621 assert_eq!(cfg.table_include, vec!["public.orders", "public.users"]);
622 assert_eq!(cfg.table_exclude, vec!["public.archive", "public.audit"]);
623 assert!(cfg.should_include_table("public.users"));
624 assert!(!cfg.should_include_table("public.audit"));
625 assert!(!cfg.should_include_table("users"));
626 }
627
628 #[test]
629 fn table_filters_reject_unqualified_or_empty_components() {
630 for table in ["users", ".users", "public."] {
631 let mut config = connector_config();
632 config.set("table.include", table);
633 let error = PostgresCdcConfig::from_config(&config).unwrap_err();
634 assert!(error.to_string().contains("schema"), "{table}: {error}");
635 }
636 }
637
638 #[test]
639 fn custom_ca_is_admitted_for_verified_tls() {
640 let mut config = connector_config();
641 config.set("ssl.mode", "verify-full");
642 config.set("ssl.ca.cert.path", "/certs/ca.pem");
643 let parsed = PostgresCdcConfig::from_config(&config).unwrap();
644 assert_eq!(
645 parsed.ssl_ca_cert_path,
646 Some(PathBuf::from("/certs/ca.pem"))
647 );
648 }
649
650 #[test]
651 fn plaintext_rejects_unused_ca_path() {
652 let mut config = connector_config();
653 config.set("ssl.ca.cert.path", "/certs/ca.pem");
654 let error = PostgresCdcConfig::from_config(&config).unwrap_err();
655 assert!(error.to_string().contains("ssl.mode=verify-full"));
656 }
657}