1use std::str::FromStr;
4use std::time::Duration;
5
6use async_nats::HeaderName;
7
8use crate::config::ConnectorConfig;
9use crate::error::ConnectorError;
10use crate::serde::Format;
11
12pub(super) const MAX_PENDING_PUBLISH_ACKS: usize = 4_096;
15
16pub(super) const MAX_NATS_IO_TIMEOUT: Duration = Duration::from_secs(300);
19
20const NATS_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
21const NATS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum Mode {
26 Core,
28 #[default]
30 JetStream,
31}
32
33str_enum!(fromstr Mode, lowercase, ConnectorError, "invalid nats mode",
34 Core => "core";
35 JetStream => "jetstream"
36);
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum AckPolicy {
41 #[default]
43 Explicit,
44 None,
46}
47
48str_enum!(fromstr AckPolicy, lowercase, ConnectorError, "invalid ack.policy",
49 Explicit => "explicit";
50 None => "none"
51);
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum DeliverPolicy {
56 #[default]
58 All,
59 New,
61 ByStartSequence,
63 ByStartTime,
65}
66
67str_enum!(fromstr DeliverPolicy, lowercase, ConnectorError, "invalid deliver.policy",
68 All => "all";
69 New => "new";
70 ByStartSequence => "by_start_sequence";
71 ByStartTime => "by_start_time"
72);
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum SubjectSpec {
77 Literal(String),
79 Column(String),
81}
82
83#[derive(Clone, Default, PartialEq, Eq)]
86#[allow(missing_docs)]
87pub enum AuthMode {
88 #[default]
89 None,
90 UserPass {
91 user: String,
92 password: String,
93 },
94 Token(String),
95 CredsFile(String),
96}
97
98impl std::fmt::Debug for AuthMode {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 match self {
101 Self::None => f.write_str("None"),
102 Self::UserPass { user, .. } => f
103 .debug_struct("UserPass")
104 .field("user", user)
105 .field("password", &"<redacted>")
106 .finish(),
107 Self::Token(_) => f.debug_tuple("Token").field(&"<redacted>").finish(),
108 Self::CredsFile(path) => f.debug_tuple("CredsFile").field(path).finish(),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Default)]
115#[allow(missing_docs)]
116pub struct TlsConfig {
117 pub enabled: bool,
118 pub ca_location: Option<String>,
119 pub cert_location: Option<String>,
120 pub key_location: Option<String>,
121}
122
123#[derive(Debug, Clone)]
125#[allow(missing_docs)] pub struct NatsSourceConfig {
127 pub servers: Vec<String>,
128 pub mode: Mode,
129 pub auth: AuthMode,
130 pub tls: TlsConfig,
131
132 pub stream: Option<String>,
134 pub subject: Option<String>,
135 pub subject_filters: Vec<String>,
136 pub consumer: Option<String>,
137 pub deliver_policy: DeliverPolicy,
138 pub start_sequence: Option<u64>,
139 pub start_time: Option<String>, pub ack_policy: AckPolicy,
141 pub ack_wait: Duration,
142 pub max_deliver: i64,
143 pub max_ack_pending: i64,
144 pub fetch_batch: usize,
145 pub fetch_max_wait: Duration,
146 pub lag_poll_interval: Duration,
149
150 pub queue_group: Option<String>,
152
153 pub format: Format,
154}
155
156impl NatsSourceConfig {
157 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
161 let servers = parse_servers(config)?;
162 let mode = parse_or_default::<Mode>(config, "mode")?;
163 let auth = parse_auth(config)?;
164 let tls = parse_tls(config)?;
165
166 let stream = config.get("stream").map(str::to_string);
167 let subject = config.get("subject").map(str::to_string);
168 let subject_filters = config
169 .get("subject.filters")
170 .map(split_csv)
171 .unwrap_or_default();
172 let consumer = config.get("consumer").map(str::to_string);
173 let deliver_policy = parse_or_default::<DeliverPolicy>(config, "deliver.policy")?;
174 let start_sequence = parse_opt_u64(config, "start.sequence")?;
175 let start_time = config.get("start.time").map(str::to_string);
176 let ack_policy = parse_or_default::<AckPolicy>(config, "ack.policy")?;
177 let ack_wait = require_positive_duration(config, "ack.wait.ms", Duration::from_secs(60))?;
178 let max_deliver = require_positive_or_unlimited_i64(config, "max.deliver", 5)?;
179 let max_ack_pending = require_positive_or_unlimited_i64(config, "max.ack.pending", 10_000)?;
180 let fetch_batch = require_positive_usize(config, "fetch.batch", 500)?;
181 let fetch_max_wait =
182 require_positive_duration(config, "fetch.max.wait.ms", Duration::from_millis(500))?;
183 let lag_poll_interval =
184 parse_duration_ms(config, "lag.poll.interval.ms", Duration::from_secs(10))?;
185 let queue_group = config.get("queue.group").map(str::to_string);
186 let format = parse_format(config)?;
187
188 let cfg = Self {
189 servers,
190 mode,
191 auth,
192 tls,
193 stream,
194 subject,
195 subject_filters,
196 consumer,
197 deliver_policy,
198 start_sequence,
199 start_time,
200 ack_policy,
201 ack_wait,
202 max_deliver,
203 max_ack_pending,
204 fetch_batch,
205 fetch_max_wait,
206 lag_poll_interval,
207 queue_group,
208 format,
209 };
210 cfg.validate()?;
211 Ok(cfg)
212 }
213
214 fn validate(&self) -> Result<(), ConnectorError> {
215 match self.mode {
216 Mode::JetStream => {
217 if self.stream.is_none() {
218 return Err(cfg_err(
219 "[LDB-5040] jetstream mode requires 'stream' to be set",
220 ));
221 }
222 if self.consumer.is_none() {
223 return Err(cfg_err(
224 "[LDB-5041] jetstream mode requires 'consumer' (durable name) — \
225 ephemeral consumers are not supported",
226 ));
227 }
228 if self.subject.is_none() && self.subject_filters.is_empty() {
229 return Err(cfg_err(
230 "[LDB-5042] jetstream mode requires 'subject' or 'subject.filters' \
231 — the consumer must have at least one filter",
232 ));
233 }
234 if matches!(self.deliver_policy, DeliverPolicy::ByStartSequence)
235 && self.start_sequence.is_none()
236 {
237 return Err(cfg_err(
238 "[LDB-5043] deliver.policy=by_start_sequence requires 'start.sequence'",
239 ));
240 }
241 if matches!(self.deliver_policy, DeliverPolicy::ByStartTime)
242 && self.start_time.is_none()
243 {
244 return Err(cfg_err(
245 "[LDB-5044] deliver.policy=by_start_time requires 'start.time'",
246 ));
247 }
248 }
249 Mode::Core => {
250 if self.subject.is_none() {
251 return Err(cfg_err(
252 "[LDB-5045] core mode requires 'subject' (JetStream stream config \
253 does not apply)",
254 ));
255 }
256 }
257 }
258 Ok(())
259 }
260}
261
262#[derive(Debug, Clone)]
264#[allow(missing_docs)]
265pub struct NatsSinkConfig {
266 pub servers: Vec<String>,
267 pub mode: Mode,
268 pub auth: AuthMode,
269 pub tls: TlsConfig,
270 pub stream: Option<String>,
271 pub subject: SubjectSpec,
272 pub dedup_id_column: Option<String>,
273 pub min_duplicate_window: Duration,
276 pub max_pending: usize,
277 pub ack_timeout: Duration,
278 pub format: Format,
279 pub header_columns: Vec<HeaderName>,
280}
281
282impl NatsSinkConfig {
283 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
287 let servers = parse_servers(config)?;
288 let mode = parse_or_default::<Mode>(config, "mode")?;
289 let auth = parse_auth(config)?;
290 let tls = parse_tls(config)?;
291
292 let subject = match (config.get("subject"), config.get("subject.column")) {
293 (Some(s), None) => SubjectSpec::Literal(s.to_string()),
294 (None, Some(col)) => SubjectSpec::Column(col.to_string()),
295 (Some(_), Some(_)) => {
296 return Err(cfg_err(
297 "[LDB-5050] set 'subject' OR 'subject.column', not both",
298 ));
299 }
300 (None, None) => {
301 return Err(cfg_err(
302 "[LDB-5051] 'subject' or 'subject.column' is required",
303 ));
304 }
305 };
306
307 let dedup_id_column = config.get("dedup.id.column").map(str::to_string);
308
309 let cfg = Self {
310 servers,
311 mode,
312 auth,
313 tls,
314 stream: config
315 .get("stream")
316 .map(str::trim)
317 .filter(|stream| !stream.is_empty())
318 .map(str::to_string),
319 subject,
320 dedup_id_column,
321 min_duplicate_window: require_positive_duration(
322 config,
323 "min.duplicate.window.ms",
324 Duration::from_secs(120),
325 )?,
326 max_pending: require_positive_usize(config, "max.pending", MAX_PENDING_PUBLISH_ACKS)?,
327 ack_timeout: require_positive_duration(
328 config,
329 "ack.timeout.ms",
330 Duration::from_secs(30),
331 )?,
332 format: parse_format(config)?,
333 header_columns: config
334 .get("header.columns")
335 .map(split_csv)
336 .unwrap_or_default()
337 .into_iter()
338 .map(|name| {
339 if !name
340 .bytes()
341 .all(|byte| (33..=126).contains(&byte) && byte != b':')
342 {
343 return Err(cfg_err(&format!(
344 "[LDB-5066] header.columns entry '{name}' is not a valid ASCII NATS header name"
345 )));
346 }
347 HeaderName::from_str(&name).map_err(|error| {
348 cfg_err(&format!(
349 "[LDB-5066] header.columns entry '{name}' is not a valid NATS header name: {error}"
350 ))
351 })
352 })
353 .collect::<Result<_, _>>()?,
354 };
355 cfg.validate()?;
356 Ok(cfg)
357 }
358
359 fn validate(&self) -> Result<(), ConnectorError> {
360 if self.max_pending > MAX_PENDING_PUBLISH_ACKS {
361 return Err(cfg_err(&format!(
362 "max.pending must be <= {MAX_PENDING_PUBLISH_ACKS}"
363 )));
364 }
365 if self.ack_timeout > MAX_NATS_IO_TIMEOUT {
366 return Err(cfg_err(&format!(
367 "ack.timeout.ms must be <= {}",
368 MAX_NATS_IO_TIMEOUT.as_millis()
369 )));
370 }
371 if self.mode == Mode::Core && self.dedup_id_column.is_some() {
372 return Err(cfg_err(
373 "[LDB-5053] 'dedup.id.column' requires mode=jetstream because core NATS has no \
374 server-side Nats-Msg-Id deduplication",
375 ));
376 }
377 if self.dedup_id_column.is_some() && self.stream.is_none() {
378 return Err(cfg_err(
379 "[LDB-5055] 'dedup.id.column' requires 'stream' so the sink can validate its \
380 duplicate_window at startup",
381 ));
382 }
383 for h in &self.header_columns {
384 if is_reserved_header(h.as_ref()) {
385 return Err(cfg_err(&format!(
386 "[LDB-5065] header.columns entry '{h}' collides with a reserved NATS \
387 header (Nats-Msg-Id / Nats-Expected-Stream); rename the column",
388 )));
389 }
390 }
391 match self.mode {
392 Mode::JetStream if self.stream.is_none() => {
393 return Err(cfg_err(
394 "[LDB-5071] mode=jetstream requires an explicit 'stream' so durable storage, \
395 replica count, and publish routing can be validated",
396 ));
397 }
398 Mode::Core if self.stream.is_some() => {
399 return Err(cfg_err(
400 "[LDB-5071] 'stream' is valid only in mode=jetstream; remove it for core NATS",
401 ));
402 }
403 Mode::Core | Mode::JetStream => {}
404 }
405 Ok(())
406 }
407}
408
409fn is_reserved_header(name: &str) -> bool {
412 const RESERVED: &[&str] = &["Nats-Msg-Id", "Nats-Expected-Stream"];
413 RESERVED.iter().any(|r| r.eq_ignore_ascii_case(name))
414}
415
416fn cfg_err(msg: &str) -> ConnectorError {
419 ConnectorError::ConfigurationError(msg.to_string())
420}
421
422fn parse_servers(config: &ConnectorConfig) -> Result<Vec<String>, ConnectorError> {
423 let raw = config.require("servers")?;
424 let servers: Vec<String> = split_csv(raw);
425 if servers.is_empty() {
426 return Err(cfg_err("[LDB-5030] 'servers' must not be empty"));
427 }
428 Ok(servers)
429}
430
431fn split_csv(raw: &str) -> Vec<String> {
432 raw.split(',')
433 .map(str::trim)
434 .filter(|s| !s.is_empty())
435 .map(str::to_string)
436 .collect()
437}
438
439fn parse_or_default<T: std::str::FromStr + Default>(
440 config: &ConnectorConfig,
441 key: &str,
442) -> Result<T, ConnectorError>
443where
444 T::Err: std::fmt::Display,
445{
446 match config.get(key) {
447 Some(s) => s
448 .parse()
449 .map_err(|e: T::Err| cfg_err(&format!("invalid {key}: {e}"))),
450 None => Ok(T::default()),
451 }
452}
453
454fn parse_format(config: &ConnectorConfig) -> Result<Format, ConnectorError> {
455 match config.get("format") {
456 Some(s) => Format::parse(s).map_err(|e| cfg_err(&e.to_string())),
457 None => Ok(Format::Json),
458 }
459}
460
461fn parse_bool(config: &ConnectorConfig, key: &str, default: bool) -> Result<bool, ConnectorError> {
462 match config.get(key) {
463 Some(s) => s
464 .parse::<bool>()
465 .map_err(|_| cfg_err(&format!("{key} must be 'true' or 'false', got '{s}'"))),
466 None => Ok(default),
467 }
468}
469
470fn parse_opt_u64(config: &ConnectorConfig, key: &str) -> Result<Option<u64>, ConnectorError> {
471 config.get(key).map(|s| parse_int(key, s)).transpose()
472}
473
474fn parse_i64(config: &ConnectorConfig, key: &str, default: i64) -> Result<i64, ConnectorError> {
475 config.get(key).map_or(Ok(default), |s| parse_int(key, s))
476}
477
478fn parse_usize(
479 config: &ConnectorConfig,
480 key: &str,
481 default: usize,
482) -> Result<usize, ConnectorError> {
483 config.get(key).map_or(Ok(default), |s| parse_int(key, s))
484}
485
486fn require_positive_duration(
487 config: &ConnectorConfig,
488 key: &str,
489 default: Duration,
490) -> Result<Duration, ConnectorError> {
491 let v = parse_duration_ms(config, key, default)?;
492 if v.is_zero() {
493 return Err(cfg_err(&format!("{key} must be > 0")));
494 }
495 Ok(v)
496}
497
498fn require_positive_usize(
499 config: &ConnectorConfig,
500 key: &str,
501 default: usize,
502) -> Result<usize, ConnectorError> {
503 let v = parse_usize(config, key, default)?;
504 if v == 0 {
505 return Err(cfg_err(&format!("{key} must be > 0")));
506 }
507 Ok(v)
508}
509
510fn require_positive_or_unlimited_i64(
513 config: &ConnectorConfig,
514 key: &str,
515 default: i64,
516) -> Result<i64, ConnectorError> {
517 let v = parse_i64(config, key, default)?;
518 if v == 0 || v < -1 {
519 return Err(cfg_err(&format!("{key} must be > 0, or -1 for unlimited")));
520 }
521 Ok(v)
522}
523
524fn parse_duration_ms(
525 config: &ConnectorConfig,
526 key: &str,
527 default: Duration,
528) -> Result<Duration, ConnectorError> {
529 config.get(key).map_or(Ok(default), |s| {
530 parse_int::<u64>(key, s).map(Duration::from_millis)
531 })
532}
533
534fn parse_int<T: std::str::FromStr>(key: &str, raw: &str) -> Result<T, ConnectorError> {
535 raw.parse::<T>()
536 .map_err(|_| cfg_err(&format!("{key} must be an integer, got '{raw}'")))
537}
538
539fn parse_auth(config: &ConnectorConfig) -> Result<AuthMode, ConnectorError> {
540 let mode = config.get("auth.mode").unwrap_or("none");
541 match mode {
542 "none" | "" => {
543 if config.get("user").is_some()
544 || config.get("password").is_some()
545 || config.get("token").is_some()
546 || config.get("creds.file").is_some()
547 {
548 return Err(cfg_err(
549 "[LDB-5063] credentials set but auth.mode=none; \
550 set auth.mode explicitly or remove the credentials",
551 ));
552 }
553 Ok(AuthMode::None)
554 }
555 "user_pass" => {
556 let user = config
557 .get("user")
558 .ok_or_else(|| cfg_err("[LDB-5060] auth.mode=user_pass requires 'user'"))?
559 .to_string();
560 let password = config
561 .get("password")
562 .ok_or_else(|| cfg_err("[LDB-5060] auth.mode=user_pass requires 'password'"))?
563 .to_string();
564 Ok(AuthMode::UserPass { user, password })
565 }
566 "token" => {
567 let token = config
568 .get("token")
569 .ok_or_else(|| cfg_err("[LDB-5061] auth.mode=token requires 'token'"))?
570 .to_string();
571 Ok(AuthMode::Token(token))
572 }
573 "creds_file" => {
574 let path = config
575 .get("creds.file")
576 .ok_or_else(|| cfg_err("[LDB-5064] auth.mode=creds_file requires 'creds.file'"))?
577 .to_string();
578 Ok(AuthMode::CredsFile(path))
579 }
580 other => Err(cfg_err(&format!(
581 "invalid auth.mode '{other}'; expected none | user_pass | token | creds_file"
582 ))),
583 }
584}
585
586fn parse_tls(config: &ConnectorConfig) -> Result<TlsConfig, ConnectorError> {
587 let cert_location = config.get("tls.cert.location").map(str::to_string);
588 let key_location = config.get("tls.key.location").map(str::to_string);
589 if cert_location.is_some() != key_location.is_some() {
590 return Err(cfg_err(
591 "[LDB-5062] tls.cert.location and tls.key.location must both be set \
592 (mutual-TLS client cert) or both be unset",
593 ));
594 }
595 Ok(TlsConfig {
596 enabled: parse_bool(config, "tls.enabled", false)?,
597 ca_location: config.get("tls.ca.location").map(str::to_string),
598 cert_location,
599 key_location,
600 })
601}
602
603pub(super) fn build_connect_options(
610 auth: &AuthMode,
611 tls: &TlsConfig,
612) -> Result<async_nats::ConnectOptions, ConnectorError> {
613 let mut opts = async_nats::ConnectOptions::new();
614 match auth {
615 AuthMode::None => {}
616 AuthMode::UserPass { user, password } => {
617 opts = opts.user_and_password(user.clone(), password.clone());
618 }
619 AuthMode::Token(token) => {
620 opts = opts.token(token.clone());
621 }
622 AuthMode::CredsFile(path) => {
623 let contents = std::fs::read_to_string(path)
624 .map_err(|e| cfg_err(&format!("creds.file '{path}': {e}")))?;
625 opts = async_nats::ConnectOptions::with_credentials(&contents)
626 .map_err(|e| cfg_err(&format!("creds.file '{path}' invalid: {e}")))?;
627 }
628 }
629 let tls_touched = tls.enabled || tls.ca_location.is_some() || tls.cert_location.is_some();
630 if tls_touched {
631 opts = opts.require_tls(true);
632 }
633 if let Some(ca) = &tls.ca_location {
634 opts = opts.add_root_certificates(ca.into());
635 }
636 if let (Some(cert), Some(key)) = (&tls.cert_location, &tls.key_location) {
637 opts = opts.add_client_certificate(cert.into(), key.into());
638 }
639 Ok(opts
640 .connection_timeout(NATS_CONNECTION_TIMEOUT)
641 .request_timeout(Some(NATS_REQUEST_TIMEOUT)))
642}
643
644#[cfg(test)]
645#[allow(clippy::disallowed_types)]
646mod tests;