Skip to main content

rustfs_targets/target/
mysql.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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
45/// Bounds `pool.get_conn()` so an unreachable MySQL server (or an exhausted
46/// pool) cannot block the delivery thread indefinitely. A timeout maps to
47/// `TargetError::Timeout`, a connectivity error, so the payload stays queued
48/// for replay.
49const MYSQL_CONN_CHECKOUT_TIMEOUT: Duration = Duration::from_secs(15);
50/// Absolute ceiling for one INSERT, including pool checkout and server execution.
51const MYSQL_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);
52
53/// Name of the optional idempotency-key column / primary key. Present on tables
54/// created by this target; absent on legacy two-column tables.
55const MYSQL_EVENT_ID_COLUMN: &str = "event_id";
56
57/// Modification timestamps of the three TLS material files, used to avoid
58/// re-reading and re-hashing certificate files on every pool checkout.
59///
60/// The inline TLS fingerprint is only recomputed when one of these mtimes
61/// changes, which still catches on-disk rotation while eliminating the
62/// per-send file reads.
63#[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    /// Reads the current mtimes of the configured TLS files. A missing/empty
72    /// path yields `None`; an unreadable path also yields `None`, which is
73    /// treated conservatively as "changed" so the fingerprint is recomputed.
74    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
89/// Checks out a connection from the pool under a Tokio timeout.
90///
91/// `get_conn()` failures are always transient here (connection lost or pool
92/// temporarily exhausted), so both an error and a timeout map to a
93/// connectivity error that keeps the payload queued for replay.
94async 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
105/// INSERT for tables that carry the `event_id` idempotency key. Replays of the
106/// same physical event share the same key, so `ON DUPLICATE KEY UPDATE` makes
107/// the write a no-op instead of appending a duplicate audit row.
108pub(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
116/// Legacy INSERT for pre-existing two-column tables that lack the `event_id`
117/// key. Idempotency is not available in this mode (replays may duplicate).
118pub(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
122/// DDL used to create the target table. Tables created here carry the
123/// `event_id` primary key so that store replays are idempotent.
124pub(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/// Arguments for configuring a MySQL notification target.
135///
136/// Contains all configuration values needed to connect to a MySQL/TiDB
137/// database and write event notification records.
138#[derive(Clone)]
139pub struct MySqlArgs {
140    /// Whether the target is enabled
141    pub enable: bool,
142    /// MySQL data source name in format: `<user>:<password>@tcp(<host>:<port>)/<database>`
143    pub dsn_string: String,
144    /// Target table name, accepts `identifier` or `database.identifier`
145    pub table: String,
146    /// Write format (currently only `access` is supported)
147    pub format: String,
148    /// Optional custom CA certificate file for TLS server verification
149    pub tls_ca: String,
150    /// Optional client certificate chain file for mutual TLS
151    pub tls_client_cert: String,
152    /// Optional client private key file for mutual TLS
153    pub tls_client_key: String,
154    /// Directory for persistent queue storage; must be an absolute path if non-empty
155    pub queue_dir: String,
156    /// Maximum number of events stored in the local queue
157    pub queue_limit: u64,
158    /// Maximum number of open MySQL connections in the pool (0 relies on the underlying library default)
159    pub max_open_connections: usize,
160    /// The target type (notify or audit)
161    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    /// Validates the MySQL target configuration.
184    pub fn validate(&self) -> Result<(), TargetError> {
185        // If the target is disabled, validation is skipped.
186        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/// Parsed representation of a MySQL DSN string.
232///
233/// Produced by [`MySqlDsn::parse`] and consumed by the MySQL
234/// target runtime to build connection options.
235#[derive(Clone, PartialEq, Eq)]
236pub struct MySqlDsn {
237    /// MySQL user name
238    pub user: String,
239    /// MySQL password (plaintext, must be redacted before logging)
240    pub password: String,
241    /// MySQL server hostname or IP address
242    pub host: String,
243    /// MySQL server TCP port
244    pub port: u16,
245    /// Target database name
246    pub database: String,
247    /// Whether TLS is enabled
248    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    /// Parses a MySQL DSN string into its components.
266    ///
267    /// Supported formats:
268    /// ```text
269    /// <user>:<password>@tcp(<host>:<port>)/<database>
270    /// mysql://<user>:<password>@tcp(<host>:<port>)/<database>
271    /// ```
272    ///
273    /// Only `?tls=true`, `?tls=false`, and bare `?tls` are accepted;
274    /// other TLS query parameters (`verify_ca`, etc.) are rejected.
275    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
390/// Returns a redacted version of the DSN string with the password replaced by `***`.
391///
392/// The credentials/host boundary is the *last* `@` before the `tcp(...)` host
393/// component. Splitting on the first `@` would leak the tail of a password that
394/// itself contains `@` (e.g. `user:p@ss@tcp(host:3306)/db`). We therefore split
395/// on the last `@` and replace the entire password segment.
396pub(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            // `user` is everything before the first `:`; the password (which may
407            // contain `@` or `:`) is fully replaced, so nothing after it leaks.
408            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
487/// Extracts `event_time` from a serialized event JSON body.
488///
489/// Reads `Records[0].eventTime` from the JSON payload, parses it as an
490/// RFC 3339 timestamp, and returns it formatted as a MySQL DATETIME(6)
491/// string (`YYYY-MM-DD HH:MM:SS.ffffff`).
492///
493/// Returns an error if the field is missing, not a string, or cannot
494/// be parsed; never falls back to the current time.
495pub(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
537/// Validates the required `event_time`/`event_data` columns and reports whether
538/// the optional `event_id` idempotency key column is present.
539///
540/// Returns `Ok(true)` when the `event_id` column exists (idempotent inserts
541/// available), `Ok(false)` for a valid legacy two-column table.
542async 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
603/// A notification target that writes events to a MySQL/TiDB table.
604///
605/// Each event is appended as a new row with `event_time` and `event_data`
606/// columns. The target supports at-least-once delivery semantics via a
607/// local `QueueStore` that replays events after transient MySQL outages.
608///
609/// # Configuration example using `rc`
610///
611/// ```bash
612/// rc admin config set ALIAS notify_mysql:primary \
613///   enable=on \
614///   dsn_string="rustfs:password@tcp(mysql.example.com:3306)/rustfs_events?tls=true" \
615///   table="rustfs_events" \
616///   tls_ca="/etc/ssl/mysql/ca.pem" \
617///   tls_client_cert="/etc/ssl/mysql/client.pem" \
618///   tls_client_key="/etc/ssl/mysql/client.key" \
619///   queue_dir="/var/lib/rustfs/events" \
620///   queue_limit="100000" \
621///   max_open_connections="2"
622/// ```
623///
624/// # Environment variables
625///
626/// ```bash
627/// RUSTFS_NOTIFY_MYSQL_ENABLE=on
628/// RUSTFS_NOTIFY_MYSQL_DSN_STRING=rustfs:password@tcp(127.0.0.1:3306)/rustfs_events
629/// RUSTFS_NOTIFY_MYSQL_TABLE=rustfs_events
630/// RUSTFS_NOTIFY_MYSQL_TLS_CA=/etc/ssl/mysql/ca.pem
631/// RUSTFS_NOTIFY_MYSQL_TLS_CLIENT_CERT=/etc/ssl/mysql/client.pem
632/// RUSTFS_NOTIFY_MYSQL_TLS_CLIENT_KEY=/etc/ssl/mysql/client.key
633/// RUSTFS_NOTIFY_MYSQL_QUEUE_DIR=/opt/rustfs/events
634/// RUSTFS_NOTIFY_MYSQL_QUEUE_LIMIT=100000
635/// RUSTFS_NOTIFY_MYSQL_MAX_OPEN_CONNECTIONS=2
636/// ```
637pub struct MySqlTarget<E>
638where
639    E: PluginEvent,
640{
641    /// Unique target identifier (name + type)
642    id: TargetID,
643    /// Parsed configuration for this MySQL target
644    args: MySqlArgs,
645    /// Optional persistent queue store for at-least-once delivery
646    store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
647    /// Lazily-initialized MySQL connection pool
648    pool: Arc<Mutex<Option<Pool>>>,
649    /// TLS fingerprint tracking for hot reload (inline fallback path)
650    tls_state: Arc<parking_lot::Mutex<super::TargetTlsState>>,
651    /// Cached mtimes of the TLS material files. The inline fingerprint is only
652    /// recomputed when these change, avoiding a per-send read of all three cert
653    /// files.
654    tls_mtime_cache: Arc<parking_lot::Mutex<Option<TlsFileMtimes>>>,
655    /// Whether the target table carries the `event_id` idempotency key. Set when
656    /// the pool is built; when `false` the legacy (non-idempotent) insert is used
657    /// for backward compatibility with pre-existing two-column tables.
658    idempotency_supported: Arc<AtomicBool>,
659    /// When present, the adapter provides coordinator-managed TLS material;
660    /// otherwise the inline fingerprint path is used as a fallback.
661    tls_adapter: Option<TlsReloadAdapter<Pool>>,
662    /// Success/failure counters exposed via `delivery_snapshot`
663    delivery_counters: Arc<TargetDeliveryCounters>,
664    /// Zero-sized marker for the event type `E`
665    _phantom: PhantomData<E>,
666}
667
668impl<E> MySqlTarget<E>
669where
670    E: PluginEvent,
671{
672    /// Creates a new MySqlTarget.
673    ///
674    /// The target starts without a TLS reload coordinator. Use
675    /// `TlsReloadAdapter::try_register` to opt into coordinated TLS hot-reload.
676    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 is lazily initialized on first use to avoid unnecessary connections at startup and allow for better error handling
697            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    /// Returns or lazily initializes the MySQL connection pool.
708    ///
709    /// When `tls_adapter` is present (coordinator-managed), the pool
710    /// is sourced from the coordinator's published material.
711    /// Otherwise, the inline fingerprint-based path is used as a fallback.
712    ///
713    /// # Errors
714    ///
715    /// | Scenario | Error variant |
716    /// |---|---|
717    /// | Connection refused / host unreachable / TLS handshake failed | `NotConnected` |
718    /// | `SELECT 1` health check failed | `NotConnected` |
719    /// | DDL permission denied / `CREATE TABLE` failed | `Initialization` |
720    /// | Existing table has incompatible schema | `Initialization` |
721    /// | DSN parse failure / invalid config | `Configuration` |
722    async fn get_or_init_pool(&self) -> Result<Pool, TargetError> {
723        // Adapter-managed path: use the material directly from the coordinator.
724        if let Some(adapter) = &self.tls_adapter {
725            let pool: Pool = (*adapter.current_material()).clone();
726
727            // Ensure the pool is also stored locally so that close() can drain it.
728            {
729                let mut guard = self.pool.lock().await;
730                *guard = Some(pool.clone());
731            }
732            return Ok(pool);
733        }
734
735        // Inline fingerprint fallback path (no coordinator).
736        //
737        // Recomputing the TLS content fingerprint reads and hashes up to three
738        // certificate files. To avoid doing that on every checkout, we first
739        // compare the cheap file mtimes and only recompute the fingerprint when
740        // a file's mtime changed (or on the first call).
741        let current_mtimes = TlsFileMtimes::read(&self.args);
742        let mtimes_unchanged = {
743            let cache = self.tls_mtime_cache.lock();
744            cache.as_ref() == Some(&current_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                // Disconnect the old pool before dropping it so its connections
757                // are closed gracefully instead of leaked on TLS rotation.
758                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        // Double-check: another caller may have initialized the pool
782        // while we were doing I/O.
783        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    /// Inserts an event into the MySQL table.
797    ///
798    /// `event_id` is a stable per-event identifier used as the idempotency key:
799    /// the store key for replays, or a fresh UUID for immediate delivery. When
800    /// the table carries the `event_id` primary key, the insert is idempotent
801    /// (`ON DUPLICATE KEY UPDATE` no-op) so a replay after a lost ack does not
802    /// append a duplicate audit row. On legacy two-column tables the key is
803    /// ignored and the legacy insert is used.
804    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            // At this point the pool has already been initialized (get_or_init_pool
822            // succeeded above), so get_conn() failures are always transient: the
823            // connection was lost or the pool is temporarily exhausted.
824            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(&quoted_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(&quoted_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
863/// Builds a MySQL connection pool from the given args, including TLS setup,
864/// DDL table creation, and schema validation.
865///
866/// This is a standalone function so it can be called both from
867/// `get_or_init_pool` (inline fallback) and from `build_tls_material`
868/// (coordinator path).
869///
870/// Returns the pool together with a boolean indicating whether the target table
871/// carries the `event_id` idempotency key (`true`) or is a legacy two-column
872/// table (`false`).
873async 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    // When max_open_connections is 0, no explicit upper bound is set —
908    // mysql_async uses its default pool constraints (10–100).
909    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    // Uses a double-check pattern: the mutex guard is only held for
920    // short reads/writes to the pool cache. All I/O (connecting,
921    // DDL, schema validation) happens outside the lock so that
922    // concurrent callers are not blocked by a slow MySQL server.
923    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    // Tables created here carry the `event_id` primary key so that store
929    // replays are idempotent. Pre-existing legacy tables are left untouched by
930    // `CREATE TABLE IF NOT EXISTS`.
931    conn.query_drop(mysql_create_table_sql(&quoted_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
948/// Maps a mysql_async error to `TargetError`:
949/// - `Io`/`Driver` → `NotConnected` (connection lost, fixed-delay retry)
950/// - `Server(1213|1205|1040)` → `Timeout` (deadlock/lock timeout/too
951///   many connections, exponential-backoff retry)
952/// - everything else → `Request` (permanent failure)
953pub(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            // get_or_init_pool has already verified connectivity, DDL, and
994            // schema, so a SELECT 1 failure here is always transient
995            // (connection lost). No need to classify error codes.
996            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            // No queue: deliver immediately. A fresh UUID is the idempotency
1020            // key so caller-side retries produce distinct rows.
1021            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                // If the payload is missing the required eventTime field or it
1038                // cannot be parsed, we consider it corrupted and drop it to
1039                // avoid blocking the queue with undeliverable entries.
1040                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                // attempt to delete the corrupted entry from the store if possible
1047                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        // Use the stable store key as the idempotency key so replays of the
1061        // same physical event are deduplicated by the `event_id` primary key.
1062        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        // Adapter cleanup is done by the coordinator; no local state to reset.
1089
1090        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            // MySQL targets record no terminal failures and keep no failed store.
1119            0,
1120        )
1121    }
1122
1123    fn record_final_failure(&self) {
1124        self.delivery_counters.record_final_failure();
1125    }
1126}
1127
1128/// Coordinated TLS hot-reload implementation for MySQL targets.
1129///
1130/// The coordinator calls these methods on a background poll loop to detect
1131/// TLS file changes and rebuild the connection pool without restarting.
1132#[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        // A password containing '@' must not leak its tail into the redacted
1282        // output; the credentials/host boundary is the last '@'.
1283        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        // With enable=false and no real MySQL, the insert will fail at
1570        // pool init. But send_raw_from_store validates event_time before
1571        // insert, so valid payloads pass the time check. We verify the
1572        // payload is NOT treated as corrupted.
1573        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        // Verify entry is NOT deleted on non-Dropped errors
1579        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}