1use crate::plugin::PluginEvent;
16use crate::{
17 StoreError, Target,
18 arn::TargetID,
19 error::TargetError,
20 runtime::tls::{
21 ReloadableTargetTls, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode, fingerprint::TargetTlsGeneration,
22 validate::validate_tls_material,
23 },
24 store::{Key, Store},
25 target::{
26 ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
27 TargetType, build_queued_payload, delete_stored_payload, is_connectivity_error, open_target_queue_store,
28 persist_queued_payload_to_store, redacted_secret, with_delivery_deadline,
29 },
30};
31use async_trait::async_trait;
32use mysql_async::{Conn, Opts, OptsBuilder, Pool, PoolConstraints, PoolOpts, SslOpts, prelude::Queryable};
33use rustfs_config::{MYSQL_TLS_CA, MYSQL_TLS_CLIENT_CERT, MYSQL_TLS_CLIENT_KEY};
34use rustfs_tls_runtime::{load_certs, load_private_key};
35use std::fmt;
36use std::marker::PhantomData;
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::sync::atomic::{AtomicBool, Ordering};
40use std::time::{Duration, SystemTime};
41use tokio::sync::Mutex;
42use tracing::{debug, error, info, warn};
43use uuid::Uuid;
44
45const MYSQL_CONN_CHECKOUT_TIMEOUT: Duration = Duration::from_secs(15);
50const MYSQL_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);
52
53const MYSQL_EVENT_ID_COLUMN: &str = "event_id";
56
57#[derive(Clone, PartialEq, Eq)]
64struct TlsFileMtimes {
65 ca: Option<SystemTime>,
66 client_cert: Option<SystemTime>,
67 client_key: Option<SystemTime>,
68}
69
70impl TlsFileMtimes {
71 fn read(args: &MySqlArgs) -> Self {
75 fn mtime(path: &str) -> Option<SystemTime> {
76 if path.is_empty() {
77 return None;
78 }
79 std::fs::metadata(path).and_then(|m| m.modified()).ok()
80 }
81 TlsFileMtimes {
82 ca: mtime(&args.tls_ca),
83 client_cert: mtime(&args.tls_client_cert),
84 client_key: mtime(&args.tls_client_key),
85 }
86 }
87}
88
89async fn checkout_conn(pool: &Pool) -> Result<Conn, TargetError> {
95 match tokio::time::timeout(MYSQL_CONN_CHECKOUT_TIMEOUT, pool.get_conn()).await {
96 Ok(Ok(conn)) => Ok(conn),
97 Ok(Err(_)) => Err(TargetError::NotConnected),
98 Err(_) => Err(TargetError::Timeout(format!(
99 "MySQL connection checkout timed out after {}s",
100 MYSQL_CONN_CHECKOUT_TIMEOUT.as_secs()
101 ))),
102 }
103}
104
105pub(crate) fn mysql_insert_sql_with_event_id(quoted_table: &str) -> String {
109 format!(
110 "INSERT INTO {quoted_table} ({MYSQL_EVENT_ID_COLUMN}, event_time, event_data) \
111 VALUES (?, ?, CAST(? AS JSON)) \
112 ON DUPLICATE KEY UPDATE {MYSQL_EVENT_ID_COLUMN} = {MYSQL_EVENT_ID_COLUMN}"
113 )
114}
115
116pub(crate) fn mysql_insert_sql_legacy(quoted_table: &str) -> String {
119 format!("INSERT INTO {quoted_table} (event_time, event_data) VALUES (?, CAST(? AS JSON))")
120}
121
122pub(crate) fn mysql_create_table_sql(quoted_table: &str) -> String {
125 format!(
126 "CREATE TABLE IF NOT EXISTS {quoted_table} (\
127 {MYSQL_EVENT_ID_COLUMN} VARCHAR(255) NOT NULL, \
128 event_time DATETIME(6) NOT NULL, \
129 event_data JSON NOT NULL, \
130 PRIMARY KEY ({MYSQL_EVENT_ID_COLUMN}))"
131 )
132}
133
134#[derive(Clone)]
139pub struct MySqlArgs {
140 pub enable: bool,
142 pub dsn_string: String,
144 pub table: String,
146 pub format: String,
148 pub tls_ca: String,
150 pub tls_client_cert: String,
152 pub tls_client_key: String,
154 pub queue_dir: String,
156 pub queue_limit: u64,
158 pub max_open_connections: usize,
160 pub target_type: TargetType,
162}
163
164impl fmt::Debug for MySqlArgs {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 f.debug_struct("MySqlArgs")
167 .field("enable", &self.enable)
168 .field("dsn_string", &redact_mysql_dsn(&self.dsn_string))
169 .field("table", &self.table)
170 .field("format", &self.format)
171 .field("tls_ca", &self.tls_ca)
172 .field("tls_client_cert", &self.tls_client_cert)
173 .field("tls_client_key", &redacted_secret(&self.tls_client_key))
174 .field("queue_dir", &self.queue_dir)
175 .field("queue_limit", &self.queue_limit)
176 .field("max_open_connections", &self.max_open_connections)
177 .field("target_type", &self.target_type)
178 .finish()
179 }
180}
181
182impl MySqlArgs {
183 pub fn validate(&self) -> Result<(), TargetError> {
185 if !self.enable {
187 return Ok(());
188 }
189
190 if self.dsn_string.trim().is_empty() {
191 return Err(TargetError::Configuration("MySQL dsn_string cannot be empty".to_string()));
192 }
193
194 let _ = MySqlDsn::parse(&self.dsn_string)?;
195
196 validate_table_name(&self.table)?;
197
198 if self.format != "access" {
199 return Err(TargetError::Configuration(format!(
200 "MySQL format '{}' is not supported; only 'access' is available",
201 self.format
202 )));
203 }
204
205 if self.tls_client_cert.is_empty() != self.tls_client_key.is_empty() {
206 return Err(TargetError::Configuration(format!(
207 "MySQL {MYSQL_TLS_CLIENT_CERT} and {MYSQL_TLS_CLIENT_KEY} must be specified together"
208 )));
209 }
210 if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
211 return Err(TargetError::Configuration(format!("{MYSQL_TLS_CA} must be an absolute path")));
212 }
213 if !self.tls_client_cert.is_empty() && !Path::new(&self.tls_client_cert).is_absolute() {
214 return Err(TargetError::Configuration(format!("{MYSQL_TLS_CLIENT_CERT} must be an absolute path")));
215 }
216 if !self.tls_client_key.is_empty() && !Path::new(&self.tls_client_key).is_absolute() {
217 return Err(TargetError::Configuration(format!("{MYSQL_TLS_CLIENT_KEY} must be an absolute path")));
218 }
219
220 if !self.queue_dir.is_empty() {
221 let path = Path::new(&self.queue_dir);
222 if !path.is_absolute() {
223 return Err(TargetError::Configuration("MySQL queue_dir must be an absolute path".to_string()));
224 }
225 }
226
227 Ok(())
228 }
229}
230
231#[derive(Clone, PartialEq, Eq)]
236pub struct MySqlDsn {
237 pub user: String,
239 pub password: String,
241 pub host: String,
243 pub port: u16,
245 pub database: String,
247 pub tls: bool,
249}
250
251impl fmt::Debug for MySqlDsn {
252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253 f.debug_struct("MySqlDsn")
254 .field("user", &self.user)
255 .field("password", &redacted_secret(&self.password))
256 .field("host", &self.host)
257 .field("port", &self.port)
258 .field("database", &self.database)
259 .field("tls", &self.tls)
260 .finish()
261 }
262}
263
264impl MySqlDsn {
265 pub fn parse(dsn_string: &str) -> Result<MySqlDsn, TargetError> {
276 let input = dsn_string.trim();
277 if input.is_empty() {
278 return Err(TargetError::Configuration("MySQL dsn_string cannot be empty".to_string()));
279 }
280
281 let (_, remainder) = split_mysql_scheme(input);
282
283 let (body, query) = match remainder.split_once('?') {
284 Some((b, q)) => (b, Some(q)),
285 None => (remainder, None),
286 };
287
288 let mut tls = false;
289 if let Some(query) = query {
290 for param in query.split('&') {
291 let param = param.trim();
292 if param.is_empty() {
293 continue;
294 }
295 let (key, value) = param.split_once('=').unwrap_or((param, ""));
296 match key.trim().to_ascii_lowercase().as_str() {
297 "tls" => {
298 let val = value.trim().to_ascii_lowercase();
299 if val == "true" || val.is_empty() {
300 tls = true;
301 } else if val == "false" {
302 tls = false;
303 } else {
304 return Err(TargetError::Configuration(format!(
305 "unsupported value '{}' for TLS query parameter; use tls=true",
306 val
307 )));
308 }
309 }
310 _ => {
311 return Err(TargetError::Configuration(format!("unsupported MySQL DSN query parameter '{}'", key)));
312 }
313 }
314 }
315 }
316
317 let Some((credentials, host_part)) = body.split_once('@') else {
318 return Err(TargetError::Configuration(
319 "MySQL dsn_string must contain user:password@tcp(host:port)/database".to_string(),
320 ));
321 };
322
323 let Some((user, password)) = credentials.split_once(':') else {
324 return Err(TargetError::Configuration("MySQL dsn_string must contain user:password".to_string()));
325 };
326
327 let user = user.trim();
328 let password = password.trim();
329
330 if user.is_empty() {
331 return Err(TargetError::Configuration("MySQL dsn_string user is empty".to_string()));
332 }
333
334 let host_part = host_part.trim();
335
336 let Some(host_part_rest) = host_part.strip_prefix("tcp(") else {
337 return Err(TargetError::Configuration("MySQL dsn_string must use tcp(host:port) format".to_string()));
338 };
339
340 let Some((host_port, rest)) = host_part_rest.split_once(')') else {
341 return Err(TargetError::Configuration(
342 "MySQL dsn_string missing closing ')' after host:port".to_string(),
343 ));
344 };
345
346 let (host, port_str) = host_port
347 .split_once(':')
348 .ok_or_else(|| TargetError::Configuration("MySQL dsn_string host:port is required".to_string()))?;
349
350 let host = host.trim();
351 let port_str = port_str.trim();
352
353 if host.is_empty() {
354 return Err(TargetError::Configuration("MySQL dsn_string host is empty".to_string()));
355 }
356
357 let port: u16 = port_str
358 .parse()
359 .map_err(|_| TargetError::Configuration(format!("MySQL dsn_string port '{}' is not a valid u16", port_str)))?;
360
361 let database = rest
362 .strip_prefix('/')
363 .ok_or_else(|| TargetError::Configuration("MySQL dsn_string must include /database after host:port".to_string()))?
364 .trim();
365
366 if database.is_empty() {
367 return Err(TargetError::Configuration("MySQL dsn_string database is empty".to_string()));
368 }
369
370 Ok(MySqlDsn {
371 user: user.to_string(),
372 password: password.to_string(),
373 host: host.to_string(),
374 port,
375 database: database.to_string(),
376 tls,
377 })
378 }
379}
380
381fn split_mysql_scheme(input: &str) -> (&str, &str) {
382 const MYSQL_SCHEME: &str = "mysql://";
383
384 match input.get(..MYSQL_SCHEME.len()) {
385 Some(prefix) if prefix.eq_ignore_ascii_case(MYSQL_SCHEME) => input.split_at(MYSQL_SCHEME.len()),
386 _ => ("", input),
387 }
388}
389
390pub(crate) fn redact_mysql_dsn(dsn_string: &str) -> String {
397 let input = dsn_string.trim();
398 if input.is_empty() {
399 return String::new();
400 }
401
402 let (prefix, remainder) = split_mysql_scheme(input);
403
404 match remainder.rsplit_once('@') {
405 Some((credentials, host_part)) => match credentials.split_once(':') {
406 Some((user, _)) => format!("{}{}:***@{}", prefix, user.trim(), host_part.trim()),
409 None => format!("{prefix}***@{}", host_part.trim()),
410 },
411 None => format!("{prefix}***"),
412 }
413}
414
415fn is_valid_identifier_segment(segment: &str) -> bool {
416 if segment.is_empty() {
417 return false;
418 }
419
420 let mut chars = segment.chars();
421 let Some(first) = chars.next() else {
422 return false;
423 };
424 if !first.is_ascii_alphabetic() && first != '_' {
425 return false;
426 }
427
428 for ch in chars {
429 if !ch.is_ascii_alphanumeric() && ch != '_' {
430 return false;
431 }
432 }
433
434 true
435}
436
437pub(crate) fn validate_table_name(table: &str) -> Result<(), TargetError> {
438 let table = table.trim();
439
440 if table.is_empty() {
441 return Err(TargetError::Configuration("MySQL table name is empty".to_string()));
442 }
443
444 if table.contains('.') {
445 let parts: Vec<&str> = table.splitn(2, '.').collect();
446 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
447 return Err(TargetError::Configuration(format!(
448 "MySQL table name '{}' is invalid; use identifier or database.identifier",
449 table
450 )));
451 }
452
453 if !is_valid_identifier_segment(parts[0]) {
454 return Err(TargetError::Configuration(format!(
455 "MySQL database name '{}' in '{}' is not a valid identifier",
456 parts[0], table
457 )));
458 }
459
460 if !is_valid_identifier_segment(parts[1]) {
461 return Err(TargetError::Configuration(format!(
462 "MySQL table name '{}' in '{}' is not a valid identifier",
463 parts[1], table
464 )));
465 }
466 } else if !is_valid_identifier_segment(table) {
467 return Err(TargetError::Configuration(format!(
468 "MySQL table name '{}' is not a valid identifier",
469 table
470 )));
471 }
472
473 Ok(())
474}
475
476pub(crate) fn quote_table_name(table: &str) -> Result<String, TargetError> {
477 let table = table.trim();
478
479 if table.contains('.') {
480 let parts: Vec<&str> = table.splitn(2, '.').collect();
481 Ok(format!("`{}`.`{}`", parts[0].trim(), parts[1].trim()))
482 } else {
483 Ok(format!("`{}`", table))
484 }
485}
486
487pub(crate) fn extract_event_time(body: &[u8]) -> Result<String, TargetError> {
496 let value: serde_json::Value =
497 serde_json::from_slice(body).map_err(|e| TargetError::Serialization(format!("Failed to parse event_data JSON: {e}")))?;
498
499 let event_time = value
500 .get("Records")
501 .and_then(|r| r.get(0))
502 .and_then(|r| r.get("eventTime"))
503 .and_then(|v| v.as_str())
504 .ok_or_else(|| TargetError::Serialization("event_data is missing Records[0].eventTime".to_string()))?;
505
506 let pieces = jiff::fmt::temporal::Pieces::parse(event_time)
507 .map_err(|e| TargetError::Serialization(format!("Failed to parse eventTime '{}': {}", event_time, e)))?;
508 let time = pieces
509 .time()
510 .ok_or_else(|| TargetError::Serialization(format!("Failed to parse eventTime '{}': missing RFC3339 time", event_time)))?;
511 if pieces.offset().is_none() {
512 return Err(TargetError::Serialization(format!(
513 "Failed to parse eventTime '{}': missing RFC3339 offset",
514 event_time
515 )));
516 }
517 if pieces.time_zone_annotation().is_some() {
518 return Err(TargetError::Serialization(format!(
519 "Failed to parse eventTime '{}': RFC3339 timestamp must not include a time zone annotation",
520 event_time
521 )));
522 }
523 let date = pieces.date();
524
525 Ok(format!(
526 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:06}",
527 date.year(),
528 date.month(),
529 date.day(),
530 time.hour(),
531 time.minute(),
532 time.second(),
533 time.subsec_nanosecond() / 1_000
534 ))
535}
536
537async fn validate_existing_schema(conn: &mut Conn, table: &str) -> Result<bool, TargetError> {
543 let quoted = quote_table_name(table)?;
544 let sql = format!("SHOW COLUMNS FROM {quoted}");
545
546 let columns: Vec<mysql_async::Row> = conn
547 .query(sql)
548 .await
549 .map_err(|e| TargetError::Initialization(format!("Failed to check MySQL table schema: {e}")))?;
550
551 let mut has_event_time = false;
552 let mut has_event_data = false;
553 let mut has_event_id = false;
554
555 for row in &columns {
556 let field: String = row.get(0).unwrap_or_default();
557 let col_type: String = row.get(1).unwrap_or_default();
558 let nullable: String = row.get(2).unwrap_or_default();
559
560 if field == MYSQL_EVENT_ID_COLUMN {
561 has_event_id = true;
562 } else if field == "event_time" {
563 has_event_time = true;
564 if col_type.to_lowercase() != "datetime(6)" {
565 return Err(TargetError::Initialization(
566 "MySQL table column 'event_time' must be DATETIME(6) to match insert precision".to_string(),
567 ));
568 }
569 if nullable.to_lowercase() != "no" {
570 return Err(TargetError::Initialization(
571 "MySQL table column 'event_time' must be NOT NULL".to_string(),
572 ));
573 }
574 } else if field == "event_data" {
575 has_event_data = true;
576 if col_type.to_lowercase() != "json" {
577 return Err(TargetError::Initialization(
578 "MySQL table column 'event_data' must be JSON type".to_string(),
579 ));
580 }
581 if nullable.to_lowercase() != "no" {
582 return Err(TargetError::Initialization(
583 "MySQL table column 'event_data' must be NOT NULL".to_string(),
584 ));
585 }
586 }
587 }
588
589 if !has_event_time {
590 return Err(TargetError::Initialization(
591 "MySQL table is missing required column 'event_time'".to_string(),
592 ));
593 }
594 if !has_event_data {
595 return Err(TargetError::Initialization(
596 "MySQL table is missing required column 'event_data'".to_string(),
597 ));
598 }
599
600 Ok(has_event_id)
601}
602
603pub struct MySqlTarget<E>
638where
639 E: PluginEvent,
640{
641 id: TargetID,
643 args: MySqlArgs,
645 store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
647 pool: Arc<Mutex<Option<Pool>>>,
649 tls_state: Arc<parking_lot::Mutex<super::TargetTlsState>>,
651 tls_mtime_cache: Arc<parking_lot::Mutex<Option<TlsFileMtimes>>>,
655 idempotency_supported: Arc<AtomicBool>,
659 tls_adapter: Option<TlsReloadAdapter<Pool>>,
662 delivery_counters: Arc<TargetDeliveryCounters>,
664 _phantom: PhantomData<E>,
666}
667
668impl<E> MySqlTarget<E>
669where
670 E: PluginEvent,
671{
672 pub fn new(id: String, args: MySqlArgs) -> Result<Self, TargetError> {
677 args.validate()?;
678
679 let target_id = TargetID::new(id, ChannelTargetType::MySql.as_str().to_string());
680
681 let queue_store = open_target_queue_store(
682 &args.queue_dir,
683 args.queue_limit,
684 args.target_type,
685 ChannelTargetType::MySql.as_str(),
686 &target_id,
687 "Failed to open MySQL queue store",
688 )?;
689
690 info!(target_id = %target_id.id, table = %args.table, "MySQL target created");
691
692 Ok(MySqlTarget {
693 id: target_id,
694 args,
695 store: queue_store,
696 pool: Arc::new(Mutex::new(None)),
698 tls_state: Arc::new(parking_lot::Mutex::new(super::TargetTlsState::default())),
699 tls_mtime_cache: Arc::new(parking_lot::Mutex::new(None)),
700 idempotency_supported: Arc::new(AtomicBool::new(false)),
701 tls_adapter: None,
702 delivery_counters: Arc::new(TargetDeliveryCounters::default()),
703 _phantom: PhantomData,
704 })
705 }
706
707 async fn get_or_init_pool(&self) -> Result<Pool, TargetError> {
723 if let Some(adapter) = &self.tls_adapter {
725 let pool: Pool = (*adapter.current_material()).clone();
726
727 {
729 let mut guard = self.pool.lock().await;
730 *guard = Some(pool.clone());
731 }
732 return Ok(pool);
733 }
734
735 let current_mtimes = TlsFileMtimes::read(&self.args);
742 let mtimes_unchanged = {
743 let cache = self.tls_mtime_cache.lock();
744 cache.as_ref() == Some(¤t_mtimes)
745 };
746
747 if !mtimes_unchanged {
748 let next_fingerprint =
749 super::build_target_tls_fingerprint(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
750 .await?;
751 let tls_changed = {
752 let tls_state_guard = self.tls_state.lock();
753 tls_state_guard.needs_update(&next_fingerprint)
754 };
755 if tls_changed {
756 let old_pool = {
759 let mut guard = self.pool.lock().await;
760 guard.take()
761 };
762 if let Some(old_pool) = old_pool
763 && let Err(err) = old_pool.disconnect().await
764 {
765 warn!(target_id = %self.id, error = %err, "Failed to disconnect stale MySQL pool during TLS reload");
766 }
767 self.tls_state.lock().refresh(next_fingerprint);
768 }
769 *self.tls_mtime_cache.lock() = Some(current_mtimes);
770 }
771
772 {
773 let guard = self.pool.lock().await;
774 if let Some(pool) = guard.as_ref() {
775 return Ok(pool.clone());
776 }
777 }
778
779 let (pool, idempotency) = build_mysql_pool_from_args(&self.args).await?;
780
781 let mut guard = self.pool.lock().await;
784 if let Some(existing) = guard.as_ref() {
785 debug!(
786 "MySQL pool for target '{}' was initialized by another task during setup; using existing pool",
787 self.id
788 );
789 return Ok(existing.clone());
790 }
791 self.idempotency_supported.store(idempotency, Ordering::Relaxed);
792 *guard = Some(pool.clone());
793 Ok(pool)
794 }
795
796 async fn insert_event(&self, body: &[u8], meta: &QueuedPayloadMeta, event_id: &str) -> Result<(), TargetError> {
805 debug!(
806 target_id = %self.id,
807 bucket = %meta.bucket_name,
808 object = %meta.object_name,
809 event = %meta.event_name,
810 payload_len = body.len(),
811 "Inserting MySQL event"
812 );
813
814 let event_time = extract_event_time(body)?;
815 let event_data =
816 std::str::from_utf8(body).map_err(|e| TargetError::Serialization(format!("Event body is not valid UTF-8: {e}")))?;
817
818 let quoted_table = quote_table_name(&self.args.table)?;
819 with_delivery_deadline(MYSQL_DELIVERY_TIMEOUT, "MySQL delivery", async {
820 let pool = self.get_or_init_pool().await?;
821 let mut conn = checkout_conn(&pool).await?;
825
826 if self.idempotency_supported.load(Ordering::Relaxed) {
827 let sql = mysql_insert_sql_with_event_id("ed_table);
828 conn.exec_drop(sql, (event_id, event_time.as_str(), event_data))
829 .await
830 .map_err(|err| map_mysql_error(err, "Failed to insert event"))?;
831 } else {
832 let sql = mysql_insert_sql_legacy("ed_table);
833 conn.exec_drop(sql, (event_time.as_str(), event_data))
834 .await
835 .map_err(|err| map_mysql_error(err, "Failed to insert event"))?;
836 }
837
838 Ok(())
839 })
840 .await?;
841
842 self.delivery_counters.record_success();
843 debug!(target_id = %self.id, "MySQL event inserted");
844 Ok(())
845 }
846
847 fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
848 Box::new(MySqlTarget::<E> {
849 id: self.id.clone(),
850 args: self.args.clone(),
851 store: self.store.as_ref().map(|s| s.boxed_clone()),
852 pool: Arc::clone(&self.pool),
853 tls_state: Arc::clone(&self.tls_state),
854 tls_mtime_cache: Arc::clone(&self.tls_mtime_cache),
855 idempotency_supported: Arc::clone(&self.idempotency_supported),
856 tls_adapter: self.tls_adapter.clone(),
857 delivery_counters: Arc::clone(&self.delivery_counters),
858 _phantom: PhantomData,
859 })
860 }
861}
862
863async fn build_mysql_pool_from_args(args: &MySqlArgs) -> Result<(Pool, bool), TargetError> {
874 let dsn = MySqlDsn::parse(&args.dsn_string)?;
875
876 let mut builder = OptsBuilder::default()
877 .user(Some(dsn.user.clone()))
878 .pass(Some(dsn.password.clone()))
879 .ip_or_hostname(dsn.host.clone())
880 .tcp_port(dsn.port)
881 .db_name(Some(dsn.database.clone()));
882
883 if dsn.tls {
884 super::ensure_rustls_provider_installed();
885 let mut ssl_opts = SslOpts::default();
886 if !args.tls_ca.is_empty() {
887 let _ =
888 load_certs(&args.tls_ca).map_err(|e| TargetError::Configuration(format!("Failed to load MySQL tls_ca: {e}")))?;
889 ssl_opts = ssl_opts.with_root_certs(vec![PathBuf::from(args.tls_ca.clone()).into()]);
890 }
891 if !args.tls_client_cert.is_empty() && !args.tls_client_key.is_empty() {
892 let _ = load_certs(&args.tls_client_cert)
893 .map_err(|e| TargetError::Configuration(format!("Failed to load MySQL tls_client_cert: {e}")))?;
894 let _ = load_private_key(&args.tls_client_key)
895 .map_err(|e| TargetError::Configuration(format!("Failed to load MySQL tls_client_key: {e}")))?;
896 let identity = mysql_async::ClientIdentity::new(
897 PathBuf::from(args.tls_client_cert.clone()).into(),
898 PathBuf::from(args.tls_client_key.clone()).into(),
899 );
900 ssl_opts = ssl_opts.with_client_identity(Some(identity));
901 }
902 builder = builder.ssl_opts(Some(ssl_opts));
903 } else {
904 warn!("MySQL target is configured without TLS. This is insecure and should not be used in production.");
905 }
906
907 if args.max_open_connections > 0 {
910 let constraints = PoolConstraints::new(1, args.max_open_connections).ok_or_else(|| {
911 TargetError::Configuration(format!("MySQL max_open_connections must be >= 1, got {}", args.max_open_connections))
912 })?;
913 builder = builder.pool_opts(PoolOpts::default().with_constraints(constraints));
914 }
915
916 let opts = Opts::from(builder);
917 let pool = Pool::new(opts);
918
919 let mut conn = checkout_conn(&pool).await?;
924
925 conn.query_drop("SELECT 1").await.map_err(|_| TargetError::NotConnected)?;
926
927 let quoted_table = quote_table_name(&args.table)?;
928 conn.query_drop(mysql_create_table_sql("ed_table))
932 .await
933 .map_err(|e| TargetError::Initialization(format!("Failed to create MySQL table: {e}")))?;
934
935 let idempotency_supported = validate_existing_schema(&mut conn, &args.table).await?;
936 if !idempotency_supported {
937 warn!(
938 table = %args.table,
939 "MySQL table lacks the '{}' idempotency key column; store replays may create duplicate rows. \
940 Add an '{}' VARCHAR(255) PRIMARY KEY column to enable exactly-once inserts.",
941 MYSQL_EVENT_ID_COLUMN, MYSQL_EVENT_ID_COLUMN
942 );
943 }
944
945 Ok((pool, idempotency_supported))
946}
947
948pub(crate) fn map_mysql_error(err: mysql_async::Error, operation: &str) -> TargetError {
954 match &err {
955 mysql_async::Error::Io(_) | mysql_async::Error::Driver(_) => TargetError::NotConnected,
956 mysql_async::Error::Server(server_err) => match server_err.code {
957 1213 | 1205 | 1040 => {
958 TargetError::Timeout(format!("MySQL transient server error {}: {}", server_err.code, server_err.message))
959 }
960 _ => TargetError::Request(format!("{operation}: {err}")),
961 },
962 _ => TargetError::Request(format!("{operation}: {err}")),
963 }
964}
965
966#[async_trait]
967impl<E> Target<E> for MySqlTarget<E>
968where
969 E: PluginEvent,
970{
971 fn id(&self) -> TargetID {
972 self.id.clone()
973 }
974
975 async fn is_active(&self) -> Result<bool, TargetError> {
976 if !self.args.enable {
977 return Ok(false);
978 }
979
980 let pool = self.get_or_init_pool().await?;
981
982 let health_result = tokio::time::timeout(tokio::time::Duration::from_secs(10), async {
983 let mut conn = pool.get_conn().await?;
984 conn.query_drop("SELECT 1").await
985 })
986 .await;
987
988 match health_result {
989 Ok(Ok(())) => {
990 debug!("MySQL target '{}' is reachable", self.id);
991 Ok(true)
992 }
993 Ok(Err(_)) => Err(TargetError::NotConnected),
997 Err(_elapsed) => Err(TargetError::Timeout("MySQL is_active health check timed out after 10s".to_string())),
998 }
999 }
1000
1001 async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
1002 let queued = match build_queued_payload(event.as_ref()) {
1003 Ok(queued) => queued,
1004 Err(err) => {
1005 self.delivery_counters.record_final_failure();
1006 return Err(err);
1007 }
1008 };
1009
1010 if let Some(store) = &self.store {
1011 if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
1012 self.delivery_counters.record_final_failure();
1013 return Err(e);
1014 }
1015
1016 debug!("Event saved to queue store for MySQL target: {}", self.id);
1017 Ok(())
1018 } else {
1019 let event_id = Uuid::new_v4().to_string();
1022 if let Err(err) = self.insert_event(&queued.body, &queued.meta, &event_id).await {
1023 self.delivery_counters.record_final_failure();
1024 return Err(err);
1025 }
1026
1027 Ok(())
1028 }
1029 }
1030
1031 async fn send_raw_from_store(&self, key: Key, body: Vec<u8>, meta: QueuedPayloadMeta) -> Result<(), TargetError> {
1032 debug!(target_id = %self.id, key = %key, payload_len = body.len(), "Sending queued payload from store to MySQL target");
1033
1034 match extract_event_time(&body) {
1035 Ok(_) => {}
1036 Err(_) => {
1037 error!(
1041 target_id = %self.id,
1042 key = %key,
1043 "Corrupted queued MySQL payload: missing or invalid Records[0].eventTime; dropping entry"
1044 );
1045
1046 if let Some(store) = &self.store
1048 && let Err(e) = delete_stored_payload(store.as_ref(), &key)
1049 {
1050 error!(target_id = %self.id, key=%key, error = %e, "Failed to delete corrupted queue entry");
1051 }
1052
1053 self.delivery_counters.record_final_failure();
1054 return Err(TargetError::Dropped(format!(
1055 "Dropped corrupted queued MySQL payload {key}: missing or invalid Records[0].eventTime"
1056 )));
1057 }
1058 }
1059
1060 let event_id = key.to_string();
1063 if let Err(e) = self.insert_event(&body, &meta, &event_id).await {
1064 if is_connectivity_error(&e) {
1065 warn!(target_id = %self.id, "MySQL not reachable, event remains in queue store");
1066 return Err(e);
1067 }
1068 error!(target_id = %self.id, error = %e, "Failed to send event from store");
1069 return Err(e);
1070 }
1071
1072 debug!(target_id = %self.id, key = %key, "MySQL event replayed from store");
1073 Ok(())
1074 }
1075
1076 async fn close(&self) -> Result<(), TargetError> {
1077 let pool = {
1078 let mut guard = self.pool.lock().await;
1079 guard.take()
1080 };
1081
1082 if let Some(pool) = pool {
1083 pool.disconnect()
1084 .await
1085 .map_err(|err| TargetError::Network(format!("Failed to disconnect MySQL pool: {err}")))?;
1086 }
1087
1088 info!("MySQL target closed: {}", self.id);
1091 Ok(())
1092 }
1093
1094 fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
1095 self.store.as_deref()
1096 }
1097
1098 fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
1099 self.clone_box()
1100 }
1101
1102 async fn init(&self) -> Result<(), TargetError> {
1103 if !self.args.enable {
1104 debug!("MySQL target '{}' is disabled, skipping initialization", self.id);
1105 return Ok(());
1106 }
1107 self.get_or_init_pool().await?;
1108 Ok(())
1109 }
1110
1111 fn is_enabled(&self) -> bool {
1112 self.args.enable
1113 }
1114
1115 fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
1116 self.delivery_counters.snapshot(
1117 self.store.as_deref().map_or(0, |store| store.len() as u64),
1118 0,
1120 )
1121 }
1122
1123 fn record_final_failure(&self) {
1124 self.delivery_counters.record_final_failure();
1125 }
1126}
1127
1128#[async_trait]
1133impl<E> ReloadableTargetTls for MySqlTarget<E>
1134where
1135 E: PluginEvent,
1136{
1137 type Material = Pool;
1138
1139 fn tls_input_set(&self) -> TargetTlsInputSet {
1140 TargetTlsInputSet {
1141 ca_path: self.args.tls_ca.clone(),
1142 client_cert_path: self.args.tls_client_cert.clone(),
1143 client_key_path: self.args.tls_client_key.clone(),
1144 target_label: format!("mysql:{}", self.id.id),
1145 }
1146 }
1147
1148 async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
1149 let (pool, idempotency) = build_mysql_pool_from_args(&self.args).await?;
1150 self.idempotency_supported.store(idempotency, Ordering::Relaxed);
1151 Ok(pool)
1152 }
1153
1154 async fn apply_tls_material(
1155 &self,
1156 _generation: TargetTlsGeneration,
1157 material: Arc<Self::Material>,
1158 _mode: ReloadApplyMode,
1159 ) -> Result<(), TargetError> {
1160 let mut guard = self.pool.lock().await;
1161 *guard = Some((*material).clone());
1162 Ok(())
1163 }
1164
1165 async fn validate_tls_files(&self) -> Result<(), TargetError> {
1166 validate_tls_material(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
1167 }
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::*;
1173 use crate::target::REDACTED_SECRET;
1174
1175 fn absolute_test_path(path: &str) -> String {
1176 std::env::temp_dir().join(path).to_string_lossy().into_owned()
1177 }
1178
1179 #[test]
1180 fn parse_dsn_format() {
1181 let dsn = MySqlDsn::parse("rustfs:secret123@tcp(mysql.example.com:3306)/rustfs_events").expect("valid DSN");
1182 assert_eq!(dsn.user, "rustfs");
1183 assert_eq!(dsn.password, "secret123");
1184 assert_eq!(dsn.host, "mysql.example.com");
1185 assert_eq!(dsn.port, 3306);
1186 assert_eq!(dsn.database, "rustfs_events");
1187 assert!(!dsn.tls);
1188 }
1189
1190 #[test]
1191 fn parse_dsn_with_mysql_prefix() {
1192 let dsn = MySqlDsn::parse("mysql://rustfs:password@tcp(127.0.0.1:3306)/mydb").expect("valid DSN with prefix");
1193 assert_eq!(dsn.user, "rustfs");
1194 assert_eq!(dsn.password, "password");
1195 assert_eq!(dsn.host, "127.0.0.1");
1196 assert_eq!(dsn.port, 3306);
1197 assert_eq!(dsn.database, "mydb");
1198 }
1199
1200 #[test]
1201 fn parse_dsn_with_mixed_case_mysql_prefix() {
1202 let dsn = MySqlDsn::parse("MySQL://rustfs:password@tcp(127.0.0.1:3306)/mydb").expect("valid DSN with mixed-case prefix");
1203 assert_eq!(dsn.user, "rustfs");
1204 assert_eq!(dsn.password, "password");
1205 assert_eq!(dsn.host, "127.0.0.1");
1206 assert_eq!(dsn.port, 3306);
1207 assert_eq!(dsn.database, "mydb");
1208 }
1209
1210 #[test]
1211 fn parse_dsn_with_tls_true() {
1212 let dsn = MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?tls=true").expect("valid DSN with TLS");
1213 assert!(dsn.tls);
1214 }
1215
1216 #[test]
1217 fn parse_dsn_with_tls_bare() {
1218 let dsn = MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?tls").expect("bare tls param");
1219 assert!(dsn.tls);
1220 }
1221
1222 #[test]
1223 fn parse_dsn_rejects_unsupported_tls_params() {
1224 let err =
1225 MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?verify_ca=true").expect_err("verify_ca should be rejected");
1226 assert!(err.to_string().contains("verify_ca"));
1227
1228 let err = MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?verify_identity=true")
1229 .expect_err("verify_identity should be rejected");
1230 assert!(err.to_string().contains("verify_identity"));
1231
1232 let err = MySqlDsn::parse("rustfs:password@tcp(127.0.0.1:3306)/mydb?built_in_roots=true")
1233 .expect_err("built_in_roots should be rejected");
1234 assert!(err.to_string().contains("built_in_roots"));
1235 }
1236
1237 #[test]
1238 fn parse_dsn_rejects_empty() {
1239 let err = MySqlDsn::parse("").expect_err("empty DSN");
1240 assert!(err.to_string().contains("empty"));
1241 }
1242
1243 #[test]
1244 fn parse_dsn_rejects_missing_at() {
1245 let err = MySqlDsn::parse("rustfs:password").expect_err("missing @");
1246 assert!(err.to_string().contains("must contain user:password@"));
1247 }
1248
1249 #[test]
1250 fn parse_dsn_rejects_non_tcp() {
1251 let err = MySqlDsn::parse("rustfs:password@unix(/tmp/mysql.sock)/mydb").expect_err("non-tcp should be rejected");
1252 assert!(err.to_string().contains("tcp("));
1253 }
1254
1255 #[test]
1256 fn redact_dsn_masks_password() {
1257 let redacted = redact_mysql_dsn("rustfs:secret123@tcp(mysql.example.com:3306)/rustfs_events");
1258 assert_eq!(redacted, "rustfs:***@tcp(mysql.example.com:3306)/rustfs_events");
1259 }
1260
1261 #[test]
1262 fn redact_dsn_with_mysql_prefix() {
1263 let redacted = redact_mysql_dsn("mysql://rustfs:secret123@tcp(127.0.0.1:3306)/mydb");
1264 assert_eq!(redacted, "mysql://rustfs:***@tcp(127.0.0.1:3306)/mydb");
1265 }
1266
1267 #[test]
1268 fn redact_dsn_with_mixed_case_mysql_prefix() {
1269 let redacted = redact_mysql_dsn("MySQL://rustfs:secret123@tcp(127.0.0.1:3306)/mydb");
1270 assert_eq!(redacted, "MySQL://rustfs:***@tcp(127.0.0.1:3306)/mydb");
1271 }
1272
1273 #[test]
1274 fn redact_dsn_empty_password() {
1275 let redacted = redact_mysql_dsn("root:@tcp(127.0.0.1:4000)/testdb");
1276 assert_eq!(redacted, "root:***@tcp(127.0.0.1:4000)/testdb");
1277 }
1278
1279 #[test]
1280 fn redact_dsn_password_containing_at_sign_does_not_leak() {
1281 let redacted = redact_mysql_dsn("rustfs:p@ss@w0rd@tcp(mysql.example.com:3306)/rustfs_events");
1284 assert_eq!(redacted, "rustfs:***@tcp(mysql.example.com:3306)/rustfs_events");
1285 assert!(!redacted.contains("ss@w0rd"));
1286 assert!(!redacted.contains("w0rd"));
1287 }
1288
1289 #[test]
1290 fn redact_dsn_password_with_at_and_prefix() {
1291 let redacted = redact_mysql_dsn("mysql://rustfs:a@b:c@tcp(127.0.0.1:3306)/mydb");
1292 assert_eq!(redacted, "mysql://rustfs:***@tcp(127.0.0.1:3306)/mydb");
1293 assert!(!redacted.contains("a@b"));
1294 }
1295
1296 #[test]
1297 fn insert_sql_with_event_id_is_idempotent() {
1298 let sql = mysql_insert_sql_with_event_id("`rustfs_events`");
1299 assert!(sql.contains("event_id, event_time, event_data"));
1300 assert!(sql.contains("CAST(? AS JSON)"));
1301 assert!(sql.contains("ON DUPLICATE KEY UPDATE event_id = event_id"));
1302 assert!(sql.contains("`rustfs_events`"));
1303 }
1304
1305 #[test]
1306 fn insert_sql_legacy_has_no_idempotency_clause() {
1307 let sql = mysql_insert_sql_legacy("`rustfs_events`");
1308 assert!(sql.contains("(event_time, event_data)"));
1309 assert!(sql.contains("CAST(? AS JSON)"));
1310 assert!(!sql.contains("event_id"));
1311 assert!(!sql.contains("ON DUPLICATE KEY"));
1312 }
1313
1314 #[test]
1315 fn create_table_sql_defines_event_id_primary_key() {
1316 let sql = mysql_create_table_sql("`my_db`.`events`");
1317 assert!(sql.contains("CREATE TABLE IF NOT EXISTS `my_db`.`events`"));
1318 assert!(sql.contains("event_id VARCHAR(255) NOT NULL"));
1319 assert!(sql.contains("event_time DATETIME(6) NOT NULL"));
1320 assert!(sql.contains("event_data JSON NOT NULL"));
1321 assert!(sql.contains("PRIMARY KEY (event_id)"));
1322 }
1323
1324 #[test]
1325 fn debug_redacts_mysql_secret_fields() {
1326 let args = MySqlArgs {
1327 enable: true,
1328 dsn_string: "rustfs:mysql-password@tcp(127.0.0.1:3306)/db".to_string(),
1329 table: "events".to_string(),
1330 format: "access".to_string(),
1331 tls_ca: String::new(),
1332 tls_client_cert: String::new(),
1333 tls_client_key: "/etc/rustfs/mysql.key".to_string(),
1334 queue_dir: String::new(),
1335 queue_limit: 0,
1336 max_open_connections: 0,
1337 target_type: TargetType::NotifyEvent,
1338 };
1339 let dsn = MySqlDsn::parse(&args.dsn_string).expect("valid DSN");
1340
1341 let rendered_args = format!("{args:?}");
1342 let rendered_dsn = format!("{dsn:?}");
1343
1344 assert!(!rendered_args.contains("mysql-password"));
1345 assert!(!rendered_args.contains("/etc/rustfs/mysql.key"));
1346 assert!(!rendered_dsn.contains("mysql-password"));
1347 assert!(rendered_args.contains("rustfs:***@"));
1348 assert!(rendered_dsn.contains(REDACTED_SECRET));
1349 }
1350
1351 #[test]
1352 fn validate_table_name_accepts_valid_identifier() {
1353 validate_table_name("rustfs_events").expect("valid table name");
1354 validate_table_name("my_db.events").expect("valid db.table");
1355 validate_table_name("_events").expect("valid starting underscore");
1356 validate_table_name("table_2").expect("valid with numbers");
1357 }
1358
1359 #[test]
1360 fn validate_table_name_rejects_invalid() {
1361 let err = validate_table_name("").expect_err("empty");
1362 assert!(err.to_string().contains("empty"));
1363
1364 let err = validate_table_name("1table").expect_err("starts with digit");
1365 assert!(err.to_string().contains("not a valid identifier"));
1366
1367 let err = validate_table_name("my-table").expect_err("contains dash");
1368 assert!(err.to_string().contains("not a valid identifier"));
1369
1370 let err = validate_table_name(".table").expect_err("empty db part");
1371 assert!(err.to_string().contains("invalid"));
1372
1373 let err = validate_table_name("db.").expect_err("empty table part");
1374 assert!(err.to_string().contains("invalid"));
1375 }
1376
1377 #[test]
1378 fn quote_table_name_quotes_simple() {
1379 let quoted = quote_table_name("rustfs_events").expect("valid");
1380 assert_eq!(quoted, "`rustfs_events`");
1381 }
1382
1383 #[test]
1384 fn quote_table_name_quotes_database_table() {
1385 let quoted = quote_table_name("my_db.events").expect("valid");
1386 assert_eq!(quoted, "`my_db`.`events`");
1387 }
1388
1389 #[test]
1390 fn extract_event_time_parses_valid_rfc3339() {
1391 let body =
1392 br#"{"EventName":"s3:ObjectCreated:Put","Key":"bucket/obj.txt","Records":[{"eventTime":"2026-05-03T10:00:00Z"}]}"#;
1393 let result = extract_event_time(body).expect("valid event_time");
1394 assert_eq!(result, "2026-05-03 10:00:00.000000");
1395 }
1396
1397 #[test]
1398 fn extract_event_time_preserves_input_offset_wall_time() {
1399 let body = br#"{"EventName":"s3:ObjectCreated:Put","Records":[{"eventTime":"2026-05-03T10:00:00.123456789+08:00"}]}"#;
1400 let result = extract_event_time(body).expect("valid event_time");
1401 assert_eq!(result, "2026-05-03 10:00:00.123456");
1402 }
1403
1404 #[test]
1405 fn extract_event_time_missing_field_errors() {
1406 let body = br#"{"EventName":"s3:ObjectCreated:Put","Key":"bucket/obj.txt","Records":[]}"#;
1407 let err = extract_event_time(body).expect_err("missing eventTime should fail");
1408 assert!(err.to_string().contains("missing Records[0].eventTime"));
1409 }
1410
1411 #[test]
1412 fn extract_event_time_non_string_errors() {
1413 let body = br#"{"EventName":"s3:ObjectCreated:Put","Records":[{"eventTime":123}]}"#;
1414 let err = extract_event_time(body).expect_err("non-string eventTime should fail");
1415 assert!(err.to_string().contains("missing Records[0].eventTime"));
1416 }
1417
1418 #[test]
1419 fn extract_event_time_malformed_rfc3339_errors() {
1420 let body = br#"{"Records":[{"eventTime":"not-a-date"}]}"#;
1421 let err = extract_event_time(body).expect_err("malformed date should fail");
1422 assert!(err.to_string().contains("Failed to parse eventTime"));
1423 }
1424
1425 #[test]
1426 fn extract_event_time_without_offset_errors() {
1427 let body = br#"{"Records":[{"eventTime":"2026-05-03T10:00:00"}]}"#;
1428 let err = extract_event_time(body).expect_err("missing offset should fail");
1429 assert!(err.to_string().contains("missing RFC3339 offset"));
1430 }
1431
1432 #[test]
1433 fn extract_event_time_with_time_zone_annotation_errors() {
1434 let body = br#"{"Records":[{"eventTime":"2026-05-03T10:00:00+08:00[Asia/Shanghai]"}]}"#;
1435 let err = extract_event_time(body).expect_err("time zone annotation should fail");
1436 assert!(err.to_string().contains("must not include a time zone annotation"));
1437 }
1438
1439 #[test]
1440 fn extract_event_time_missing_records_errors() {
1441 let body = br#"{"EventName":"s3:ObjectCreated:Put"}"#;
1442 let err = extract_event_time(body).expect_err("missing Records should fail");
1443 assert!(err.to_string().contains("missing Records[0].eventTime"));
1444 }
1445
1446 #[test]
1447 fn queued_payload_round_trip_preserves_event_data() {
1448 let entity = EntityTarget {
1449 object_name: "bucket%2Fobj.txt".to_string(),
1450 bucket_name: "testbucket".to_string(),
1451 event_name: rustfs_s3_types::EventName::ObjectCreatedPut,
1452 data: serde_json::json!({"eventTime": "2026-05-03T10:00:00Z"}),
1453 };
1454
1455 let payload = build_queued_payload(&entity).expect("build payload");
1456 let encoded = payload.encode().expect("encode");
1457 let decoded = QueuedPayload::decode(&encoded).expect("decode");
1458
1459 assert_eq!(decoded.meta.event_name, payload.meta.event_name);
1460 assert_eq!(decoded.meta.bucket_name, "testbucket");
1461 assert_eq!(decoded.meta.object_name, "bucket%2Fobj.txt");
1462 assert_eq!(decoded.meta.content_type, "application/json");
1463
1464 let body_str = std::str::from_utf8(&decoded.body).expect("utf8 body");
1465 assert!(body_str.contains("\"EventName\""));
1466 assert!(body_str.contains("\"Key\""));
1467 assert!(body_str.contains("testbucket"));
1468 assert!(body_str.contains("\"Records\""));
1469 assert!(body_str.contains("\"eventTime\""));
1470 }
1471
1472 #[test]
1473 fn send_raw_from_store_drops_corrupted_payload() {
1474 let tmpdir = tempfile::TempDir::new().expect("temp dir");
1475 let queue_dir = tmpdir.path().to_str().expect("valid path").to_string();
1476
1477 let target: MySqlTarget<serde_json::Value> = MySqlTarget::new(
1478 "test-corrupted".to_string(),
1479 MySqlArgs {
1480 enable: false,
1481 dsn_string: "rustfs:pass@tcp(127.0.0.1:3306)/db".to_string(),
1482 table: "events".to_string(),
1483 format: "access".to_string(),
1484 tls_ca: String::new(),
1485 tls_client_cert: String::new(),
1486 tls_client_key: String::new(),
1487 queue_dir,
1488 queue_limit: 10,
1489 max_open_connections: 2,
1490 target_type: TargetType::NotifyEvent,
1491 },
1492 )
1493 .expect("valid args");
1494
1495 let body = br#"{"Records":[]}"#.to_vec();
1496 let meta = QueuedPayloadMeta::new(
1497 rustfs_s3_types::EventName::ObjectCreatedPut,
1498 "testbucket".to_string(),
1499 "obj.txt".to_string(),
1500 "application/json",
1501 body.len(),
1502 );
1503
1504 let encoded = QueuedPayload::new(meta.clone(), body.clone())
1505 .encode()
1506 .expect("encode queued payload");
1507
1508 let stored_key = target.store().unwrap().put_raw(&encoded).expect("put raw");
1509
1510 let rt = tokio::runtime::Runtime::new().expect("runtime");
1511 let result = rt.block_on(target.send_raw_from_store(stored_key.clone(), body, meta));
1512
1513 match result {
1514 Err(TargetError::Dropped(msg)) => {
1515 assert!(msg.contains("Dropped"));
1516 assert!(msg.contains("eventTime"));
1517 }
1518 other => panic!("expected TargetError::Dropped, got {:?}", other),
1519 }
1520
1521 assert!(
1522 target.store().unwrap().get_raw(&stored_key).is_err(),
1523 "corrupted entry should have been deleted from store"
1524 );
1525
1526 assert_eq!(target.delivery_snapshot().failed_messages, 1);
1527 }
1528
1529 #[test]
1530 fn send_raw_from_store_replays_valid_payload() {
1531 let tmpdir = tempfile::TempDir::new().expect("temp dir");
1532 let queue_dir = tmpdir.path().to_str().expect("valid path").to_string();
1533
1534 let target: MySqlTarget<serde_json::Value> = MySqlTarget::new(
1535 "test-valid-replay".to_string(),
1536 MySqlArgs {
1537 enable: false,
1538 dsn_string: "rustfs:pass@tcp(127.0.0.1:3306)/db".to_string(),
1539 table: "events".to_string(),
1540 format: "access".to_string(),
1541 tls_ca: String::new(),
1542 tls_client_cert: String::new(),
1543 tls_client_key: String::new(),
1544 queue_dir,
1545 queue_limit: 10,
1546 max_open_connections: 2,
1547 target_type: TargetType::NotifyEvent,
1548 },
1549 )
1550 .expect("valid args");
1551
1552 let body =
1553 br#"{"EventName":"s3:ObjectCreated:Put","Key":"bucket/obj.txt","Records":[{"eventTime":"2026-05-03T10:00:00Z"}]}"#
1554 .to_vec();
1555 let meta = QueuedPayloadMeta::new(
1556 rustfs_s3_types::EventName::ObjectCreatedPut,
1557 "testbucket".to_string(),
1558 "obj.txt".to_string(),
1559 "application/json",
1560 body.len(),
1561 );
1562
1563 let encoded = QueuedPayload::new(meta.clone(), body.clone())
1564 .encode()
1565 .expect("encode queued payload");
1566
1567 let stored_key = target.store().unwrap().put_raw(&encoded).expect("put raw");
1568
1569 let rt = tokio::runtime::Runtime::new().expect("runtime");
1574 let result = rt.block_on(target.send_raw_from_store(stored_key.clone(), body, meta));
1575
1576 assert!(!matches!(result, Err(TargetError::Dropped(_))), "valid payload should not return Dropped");
1577
1578 assert!(target.store().unwrap().get_raw(&stored_key).is_ok(), "valid entry should remain in store");
1580 }
1581
1582 #[test]
1583 fn validate_rejects_unpaired_tls_client_fields() {
1584 let args = MySqlArgs {
1585 enable: true,
1586 dsn_string: "rustfs:password@tcp(127.0.0.1:3306)/db".to_string(),
1587 table: "events".to_string(),
1588 format: "access".to_string(),
1589 tls_ca: String::new(),
1590 tls_client_cert: "/etc/ssl/mysql/client.pem".to_string(),
1591 tls_client_key: String::new(),
1592 queue_dir: "/tmp".to_string(),
1593 queue_limit: 100,
1594 max_open_connections: 2,
1595 target_type: TargetType::NotifyEvent,
1596 };
1597
1598 let err = args.validate().expect_err("unpaired tls client fields should fail");
1599 assert!(err.to_string().contains("must be specified together"));
1600 }
1601
1602 #[test]
1603 fn validate_rejects_relative_tls_paths() {
1604 let args = MySqlArgs {
1605 enable: true,
1606 dsn_string: "rustfs:password@tcp(127.0.0.1:3306)/db".to_string(),
1607 table: "events".to_string(),
1608 format: "access".to_string(),
1609 tls_ca: "ca.pem".to_string(),
1610 tls_client_cert: String::new(),
1611 tls_client_key: String::new(),
1612 queue_dir: "/tmp".to_string(),
1613 queue_limit: 100,
1614 max_open_connections: 2,
1615 target_type: TargetType::NotifyEvent,
1616 };
1617
1618 let err = args.validate().expect_err("relative tls_ca should fail");
1619 assert!(err.to_string().contains("absolute path"));
1620 }
1621
1622 #[test]
1623 fn validate_accepts_absolute_tls_paths() {
1624 let args = MySqlArgs {
1625 enable: true,
1626 dsn_string: "rustfs:password@tcp(127.0.0.1:3306)/db".to_string(),
1627 table: "events".to_string(),
1628 format: "access".to_string(),
1629 tls_ca: absolute_test_path("mysql-ca.pem"),
1630 tls_client_cert: absolute_test_path("mysql-client.pem"),
1631 tls_client_key: absolute_test_path("mysql-client.key"),
1632 queue_dir: absolute_test_path("mysql-queue"),
1633 queue_limit: 100,
1634 max_open_connections: 2,
1635 target_type: TargetType::NotifyEvent,
1636 };
1637
1638 args.validate().expect("absolute tls paths should pass");
1639 }
1640}