Skip to main content

rustfs_targets/target/
postgres.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
15//! PostgreSQL event notification target.
16//!
17//! Persists S3 events into a user-provided PostgreSQL table using the
18//! `Target` trait. Two output formats are supported:
19//!
20//! - `namespace` (default): single row per object key, UPSERT on each event.
21//! - `access`: append-only audit log with one row per delivered event.
22//!
23//! TLS is provided via `tokio-postgres-rustls` with rustls + aws-lc-rs.
24//! When `tls_ca` is empty the connector loads native OS trust roots.
25//! Connection pooling is delegated to `deadpool-postgres`; the pool itself
26//! is `Clone`, so no `Mutex` is required around it.
27
28use crate::plugin::PluginEvent;
29use crate::{
30    StoreError, Target,
31    arn::TargetID,
32    error::TargetError,
33    runtime::tls::{
34        ReloadableTargetTls, TargetTlsGeneration, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode,
35        validate_tls_material,
36    },
37    store::{Key, Store},
38    target::{
39        ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
40        TargetType, build_queued_payload, open_target_queue_store, persist_queued_payload_to_store, redacted_optional_secret,
41        redacted_secret, with_delivery_deadline,
42    },
43};
44use async_trait::async_trait;
45use deadpool_postgres::{Client as PooledClient, Manager, ManagerConfig, Pool, RecyclingMethod, Runtime, Timeouts};
46use rustfs_config::{POSTGRES_DSN_STRING, POSTGRES_TLS_CA, POSTGRES_TLS_CLIENT_CERT, POSTGRES_TLS_CLIENT_KEY};
47use rustfs_s3_types::EventName;
48use rustfs_tls_runtime::{load_certs, load_private_key};
49use std::fmt;
50use std::path::Path;
51use std::sync::Arc;
52use std::time::Duration;
53use tokio_postgres::Config;
54use tokio_postgres_rustls::MakeRustlsConnect;
55use tracing::{info, instrument, warn};
56use url::Url;
57use uuid::Uuid;
58
59const TARGET_LOG_KEY_FIELD: &str = "Key";
60
61/// Bounds the underlying TCP connect + startup handshake for a new backend
62/// connection so an unreachable server cannot block a pool slot forever.
63const POSTGRES_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
64/// Maximum time `pool.get()` waits for a free slot before returning a timeout.
65const POSTGRES_POOL_WAIT_TIMEOUT: Duration = Duration::from_secs(15);
66/// Maximum time to create a brand-new pooled connection.
67const POSTGRES_POOL_CREATE_TIMEOUT: Duration = Duration::from_secs(15);
68/// Maximum time to recycle (health-check) an idle pooled connection.
69const POSTGRES_POOL_RECYCLE_TIMEOUT: Duration = Duration::from_secs(10);
70/// Absolute ceiling on a single checkout, wrapping `pool.get()` in a Tokio
71/// timeout as a belt-and-suspenders guard on top of the deadpool timeouts.
72const POSTGRES_POOL_CHECKOUT_HARD_LIMIT: Duration = Duration::from_secs(20);
73/// Absolute ceiling for one SQL delivery, including pool checkout and execution.
74const POSTGRES_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);
75
76/// Returns `true` for any `s3:ObjectRemoved:*` event.
77///
78/// Used by the `namespace` format so that object deletions remove the row
79/// instead of leaving stale state behind via an UPSERT.
80fn is_object_removed_event(event: &EventName) -> bool {
81    event.as_str().starts_with("s3:ObjectRemoved")
82}
83
84/// Checks out a client from the pool, wrapping `pool.get()` in a Tokio timeout.
85///
86/// The deadpool wait/create timeouts already bound the checkout, but the outer
87/// timeout guarantees a hard ceiling even if a lower layer misbehaves. Any
88/// timeout maps to `TargetError::Timeout`, which is a connectivity error so the
89/// queue store retains the payload for replay.
90async fn checkout_client(pool: &Pool, context: &str) -> Result<PooledClient, TargetError> {
91    match tokio::time::timeout(POSTGRES_POOL_CHECKOUT_HARD_LIMIT, pool.get()).await {
92        Ok(Ok(client)) => Ok(client),
93        Ok(Err(e)) => Err(map_pool_error(e, context)),
94        Err(_) => Err(TargetError::Timeout(format!(
95            "{context}: pool checkout exceeded {}s hard limit",
96            POSTGRES_POOL_CHECKOUT_HARD_LIMIT.as_secs()
97        ))),
98    }
99}
100
101/// Output format selection for the PostgreSQL target.
102///
103/// - `Namespace`: single-row UPSERT per object key (MinIO `namespace` style).
104/// - `Access`: append-only insert per event (audit/compliance use case).
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum PostgresFormat {
107    Namespace,
108    Access,
109}
110
111impl PostgresFormat {
112    pub fn as_str(&self) -> &'static str {
113        match self {
114            PostgresFormat::Namespace => "namespace",
115            PostgresFormat::Access => "access",
116        }
117    }
118}
119
120/// Parses the `format` configuration value.
121///
122/// Accepts case-insensitive `"namespace"` or `"access"`. Defaults to
123/// `Namespace` when the value is missing or empty.
124pub fn parse_postgres_format(value: Option<&str>) -> Result<PostgresFormat, TargetError> {
125    let raw = value.unwrap_or("").trim();
126    if raw.is_empty() {
127        return Ok(PostgresFormat::Namespace);
128    }
129    match raw.to_ascii_lowercase().as_str() {
130        "namespace" => Ok(PostgresFormat::Namespace),
131        "access" => Ok(PostgresFormat::Access),
132        other => Err(TargetError::Configuration(format!(
133            "PostgreSQL format must be 'namespace' or 'access', got: {other}"
134        ))),
135    }
136}
137
138/// Parsed representation of a PostgreSQL DSN string.
139#[derive(Clone, PartialEq, Eq)]
140pub struct PostgresDsn {
141    pub host: String,
142    pub port: u16,
143    pub user: String,
144    pub password: Option<String>,
145    pub database: String,
146    pub schema: String,
147}
148
149impl fmt::Debug for PostgresDsn {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        f.debug_struct("PostgresDsn")
152            .field("host", &self.host)
153            .field("port", &self.port)
154            .field("user", &self.user)
155            .field("password", &redacted_optional_secret(self.password.as_deref()))
156            .field("database", &self.database)
157            .field("schema", &self.schema)
158            .finish()
159    }
160}
161
162impl PostgresDsn {
163    /// Parses and validates PostgreSQL DSN string.
164    ///
165    /// Supports canonical URL format like:
166    /// `postgres://user:password@host:5432/database?search_path=public`
167    pub fn parse(dsn_string: &str) -> Result<Self, TargetError> {
168        let input = dsn_string.trim();
169        if input.is_empty() {
170            return Err(TargetError::Configuration(format!("PostgreSQL {POSTGRES_DSN_STRING} cannot be empty")));
171        }
172
173        let url = Url::parse(input).map_err(|e| TargetError::Configuration(format!("invalid PostgreSQL dsn_string: {e}")))?;
174        let scheme = url.scheme().to_ascii_lowercase();
175        if scheme != "postgres" && scheme != "postgresql" {
176            return Err(TargetError::Configuration(
177                "invalid PostgreSQL dsn_string: URL scheme must be postgres or postgresql".to_string(),
178            ));
179        }
180
181        if url.host_str().is_none() {
182            return Err(TargetError::Configuration(
183                "invalid PostgreSQL dsn_string: host cannot be empty".to_string(),
184            ));
185        }
186
187        let user = url.username().trim();
188        if user.is_empty() {
189            return Err(TargetError::Configuration(
190                "invalid PostgreSQL dsn_string: user cannot be empty".to_string(),
191            ));
192        }
193
194        let host = url.host_str().unwrap_or_default().trim();
195        if host.is_empty() {
196            return Err(TargetError::Configuration(
197                "invalid PostgreSQL dsn_string: host cannot be empty".to_string(),
198            ));
199        }
200        let port = url.port().unwrap_or(5432);
201
202        let database = url.path().trim_start_matches('/').trim();
203        if database.is_empty() {
204            return Err(TargetError::Configuration(
205                "invalid PostgreSQL dsn_string: database cannot be empty".to_string(),
206            ));
207        }
208
209        let mut schema = "public".to_string();
210        for (key, value) in url.query_pairs() {
211            if !key.eq_ignore_ascii_case("search_path") {
212                return Err(TargetError::Configuration(format!(
213                    "invalid PostgreSQL dsn_string: unsupported query parameter '{key}'"
214                )));
215            }
216            let value = value.trim();
217            if value.is_empty() {
218                return Err(TargetError::Configuration(
219                    "invalid PostgreSQL dsn_string: search_path cannot be empty".to_string(),
220                ));
221            }
222            let first_schema = value
223                .split(',')
224                .next()
225                .map(str::trim)
226                .filter(|segment| !segment.is_empty())
227                .ok_or_else(|| {
228                    TargetError::Configuration(
229                        "invalid PostgreSQL dsn_string: search_path must contain at least one schema".to_string(),
230                    )
231                })?;
232            validate_pg_identifier(first_schema, "schema")?;
233            schema = first_schema.to_string();
234        }
235
236        Ok(PostgresDsn {
237            host: host.to_string(),
238            port,
239            user: user.to_string(),
240            password: url.password().map(ToOwned::to_owned),
241            database: database.to_string(),
242            schema,
243        })
244    }
245}
246
247/// Returns a redacted version of the DSN string with the password replaced by
248/// `***` while preserving non-secret connection details for diagnostics.
249pub(crate) fn redact_postgres_dsn(dsn_string: &str) -> String {
250    let input = dsn_string.trim();
251    if input.is_empty() {
252        return String::new();
253    }
254
255    let mut url = match Url::parse(input) {
256        Ok(url) => url,
257        Err(_) => return "***".to_string(),
258    };
259
260    let scheme = url.scheme().to_ascii_lowercase();
261    if scheme != "postgres" && scheme != "postgresql" {
262        return "***".to_string();
263    }
264
265    if url.password().is_some() {
266        let _ = url.set_password(Some("***"));
267    }
268
269    let mut query_pairs: Vec<(String, String)> = Vec::new();
270    let mut has_password_param = false;
271    for (key, value) in url.query_pairs() {
272        if key.eq_ignore_ascii_case("password") {
273            has_password_param = true;
274            query_pairs.push((key.into_owned(), "***".to_string()));
275        } else {
276            query_pairs.push((key.into_owned(), value.into_owned()));
277        }
278    }
279    if has_password_param {
280        url.set_query(None);
281        let mut serializer = url.query_pairs_mut();
282        for (key, value) in query_pairs {
283            serializer.append_pair(&key, &value);
284        }
285    }
286
287    url.to_string()
288}
289
290/// Validates a PostgreSQL identifier (schema or table name).
291///
292/// Accepts only `^[A-Za-z_][A-Za-z0-9_]*$`. Quoted identifiers, dots, and
293/// special characters are intentionally rejected to keep SQL string
294/// construction safe without runtime escaping.
295pub fn validate_pg_identifier(name: &str, kind: &str) -> Result<(), TargetError> {
296    if name.is_empty() {
297        return Err(TargetError::Configuration(format!("PostgreSQL {kind} cannot be empty")));
298    }
299    let mut chars = name.chars();
300    let Some(first) = chars.next() else {
301        return Err(TargetError::Configuration(format!("PostgreSQL {kind} cannot be empty")));
302    };
303    if !(first.is_ascii_alphabetic() || first == '_') {
304        return Err(TargetError::Configuration(format!(
305            "PostgreSQL {kind} must start with a letter or underscore"
306        )));
307    }
308    for c in chars {
309        if !(c.is_ascii_alphanumeric() || c == '_') {
310            return Err(TargetError::Configuration(format!(
311                "PostgreSQL {kind} must match ^[A-Za-z_][A-Za-z0-9_]*$"
312            )));
313        }
314    }
315    Ok(())
316}
317
318/// PostgreSQL target configuration.
319///
320/// Implements a manual `Debug` that redacts the DSN password to prevent secret
321/// leakage through logging or `tracing::instrument` capture.
322#[derive(Clone)]
323pub struct PostgresArgs {
324    pub enable: bool,
325
326    // Connection
327    pub dsn_string: String,
328
329    // Schema/Table/Format
330    pub schema: String,
331    pub table: String,
332    pub format: PostgresFormat,
333
334    // TLS
335    pub tls_required: bool,
336    pub tls_ca: String,
337    pub tls_client_cert: String,
338    pub tls_client_key: String,
339
340    // Queue
341    pub queue_dir: String,
342    pub queue_limit: u64,
343
344    pub target_type: TargetType,
345}
346
347impl fmt::Debug for PostgresArgs {
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        f.debug_struct("PostgresArgs")
350            .field("enable", &self.enable)
351            .field("dsn_string", &redact_postgres_dsn(&self.dsn_string))
352            .field("schema", &self.schema)
353            .field("table", &self.table)
354            .field("format", &self.format)
355            .field("tls_required", &self.tls_required)
356            .field("tls_ca", &self.tls_ca)
357            .field("tls_client_cert", &self.tls_client_cert)
358            .field("tls_client_key", &redacted_secret(&self.tls_client_key))
359            .field("queue_dir", &self.queue_dir)
360            .field("queue_limit", &self.queue_limit)
361            .field("target_type", &self.target_type)
362            .finish()
363    }
364}
365
366impl PostgresArgs {
367    pub fn validate(&self) -> Result<(), TargetError> {
368        if !self.enable {
369            return Ok(());
370        }
371
372        let parsed = PostgresDsn::parse(&self.dsn_string)?;
373
374        if self.schema.trim().is_empty() {
375            return Err(TargetError::Configuration("PostgreSQL schema cannot be empty".to_string()));
376        }
377        validate_pg_identifier(&self.schema, "schema")?;
378        if self.schema != parsed.schema {
379            return Err(TargetError::Configuration(format!(
380                "PostgreSQL schema must match DSN search_path first schema ('{}')",
381                parsed.schema
382            )));
383        }
384        validate_pg_identifier(&self.table, "table")?;
385
386        // TLS pair must be both empty or both set
387        if self.tls_client_cert.is_empty() != self.tls_client_key.is_empty() {
388            return Err(TargetError::Configuration(format!(
389                "PostgreSQL {POSTGRES_TLS_CLIENT_CERT} and {POSTGRES_TLS_CLIENT_KEY} must be specified together"
390            )));
391        }
392
393        // Optional TLS path values must be absolute when present
394        if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
395            return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CA} must be an absolute path")));
396        }
397        if !self.tls_client_cert.is_empty() && !Path::new(&self.tls_client_cert).is_absolute() {
398            return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CLIENT_CERT} must be an absolute path")));
399        }
400        if !self.tls_client_key.is_empty() && !Path::new(&self.tls_client_key).is_absolute() {
401            return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CLIENT_KEY} must be an absolute path")));
402        }
403
404        if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
405            return Err(TargetError::Configuration(
406                "PostgreSQL queue directory must be an absolute path".to_string(),
407            ));
408        }
409
410        Ok(())
411    }
412}
413
414/// Returns the qualified `"schema"."table"` SQL identifier for `args`.
415///
416/// Both schema and table are pre-validated in `PostgresArgs::validate()` so the
417/// values cannot contain quote, dot, or whitespace characters; double-quoting
418/// preserves case-sensitivity for users who created their tables with quoted
419/// identifiers.
420pub fn qualified_table(schema: &str, table: &str) -> String {
421    format!(r#""{schema}"."{table}""#)
422}
423
424/// SQL for the `namespace` format. Performs UPSERT keyed on the object key.
425pub fn namespace_upsert_sql(schema: &str, table: &str) -> String {
426    format!(
427        "INSERT INTO {} (key, value) VALUES ($1, $2::jsonb) \
428         ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
429        qualified_table(schema, table)
430    )
431}
432
433/// SQL for the `namespace` format on object removal. Deletes the row keyed on
434/// the object key so the `namespace` table stays consistent with the object
435/// lifecycle instead of retaining stale state after a delete.
436pub fn namespace_delete_sql(schema: &str, table: &str) -> String {
437    format!("DELETE FROM {} WHERE key = $1", qualified_table(schema, table))
438}
439
440/// SQL for the `access` format. Append-only with `event_id` as PK so that
441/// store-replay scenarios silently skip duplicates while distinct events still
442/// land as separate rows.
443pub fn access_insert_sql(schema: &str, table: &str) -> String {
444    format!(
445        "INSERT INTO {} (event_id, event_name, key, value, queued_at_ms) \
446         VALUES ($1, $2, $3, $4::jsonb, $5) \
447         ON CONFLICT (event_id) DO NOTHING",
448        qualified_table(schema, table)
449    )
450}
451
452/// SQL used by both `init()` and the connectivity probe to verify the table
453/// exists and is readable without producing rows or triggering side effects.
454pub fn table_probe_sql(schema: &str, table: &str) -> String {
455    format!("SELECT 1 FROM {} LIMIT 0", qualified_table(schema, table))
456}
457
458/// Builds a rustls `ClientConfig` for the PostgreSQL connection.
459///
460/// When `tls_ca` is empty the OS native trust store is used via
461/// `rustls-native-certs` (0.8 API: `CertificateResult { certs, errors }`).
462/// When `tls_client_cert` and `tls_client_key` are both set the connection
463/// uses mTLS authentication; otherwise no client cert is sent.
464pub fn build_tls_config(args: &PostgresArgs) -> Result<rustls::ClientConfig, TargetError> {
465    super::ensure_rustls_provider_installed();
466
467    let mut root_store = rustls::RootCertStore::empty();
468
469    if args.tls_ca.is_empty() {
470        let result = rustls_native_certs::load_native_certs();
471        if !result.errors.is_empty() {
472            warn!(error_count = result.errors.len(), "some native CA certs failed to load");
473        }
474        if result.certs.is_empty() {
475            return Err(TargetError::Configuration(
476                "no native CA certs available; specify tls_ca explicitly".to_string(),
477            ));
478        }
479        for cert in result.certs {
480            // Skip individual add failures; corrupted certs in the system store
481            // shouldn't block the rest from loading.
482            let _ = root_store.add(cert);
483        }
484    } else {
485        let certs =
486            load_certs(&args.tls_ca).map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CA}: {e}")))?;
487        for cert in certs {
488            root_store
489                .add(cert)
490                .map_err(|e| TargetError::Configuration(format!("failed to add CA cert: {e}")))?;
491        }
492    }
493
494    let builder = rustls::ClientConfig::builder().with_root_certificates(root_store);
495
496    let client_config = if !args.tls_client_cert.is_empty() && !args.tls_client_key.is_empty() {
497        let certs = load_certs(&args.tls_client_cert)
498            .map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CLIENT_CERT}: {e}")))?;
499        let key = load_private_key(&args.tls_client_key)
500            .map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CLIENT_KEY}: {e}")))?;
501
502        builder
503            .with_client_auth_cert(certs, key)
504            .map_err(|e| TargetError::Configuration(format!("invalid mTLS pair: {e}")))?
505    } else {
506        builder.with_no_client_auth()
507    };
508
509    Ok(client_config)
510}
511
512/// Builds the deadpool-postgres `Pool` used by the target.
513///
514/// `args.tls_required` decides whether the connection is plain TCP or wrapped
515/// in rustls. The pool is `Clone` and cheap to share across `clone_box`.
516pub fn build_pool(args: &PostgresArgs) -> Result<Pool, TargetError> {
517    let parsed = PostgresDsn::parse(&args.dsn_string)?;
518    let mut pg_config = Config::new();
519    pg_config
520        .host(&parsed.host)
521        .port(parsed.port)
522        .user(&parsed.user)
523        .dbname(&parsed.database)
524        // Bound the TCP connect + startup handshake so an unreachable backend
525        // cannot block a pool slot indefinitely.
526        .connect_timeout(POSTGRES_CONNECT_TIMEOUT)
527        .options(format!("-c search_path={}", parsed.schema));
528    if let Some(password) = parsed.password.as_deref()
529        && !password.is_empty()
530    {
531        pg_config.password(password);
532    }
533
534    let manager_config = ManagerConfig {
535        recycling_method: RecyclingMethod::Fast,
536    };
537
538    let manager = if args.tls_required {
539        let tls_config = build_tls_config(args)?;
540        let connector = MakeRustlsConnect::new(tls_config);
541        Manager::from_config(pg_config, connector, manager_config)
542    } else {
543        Manager::from_config(pg_config, tokio_postgres::NoTls, manager_config)
544    };
545
546    // Explicit wait/create/recycle timeouts guarantee that `pool.get()` always
547    // returns within a bounded time when the broker/DB is unreachable, instead
548    // of blocking the delivery thread forever. A Tokio runtime is required for
549    // deadpool to honor these timeouts.
550    Pool::builder(manager)
551        .runtime(Runtime::Tokio1)
552        .timeouts(Timeouts {
553            wait: Some(POSTGRES_POOL_WAIT_TIMEOUT),
554            create: Some(POSTGRES_POOL_CREATE_TIMEOUT),
555            recycle: Some(POSTGRES_POOL_RECYCLE_TIMEOUT),
556        })
557        .build()
558        .map_err(|e| TargetError::Configuration(format!("failed to build PostgreSQL pool: {e}")))
559}
560
561/// Classifies a PostgreSQL SQLSTATE code into the proper `TargetError` variant.
562///
563/// Split out from [`map_pg_error`] so the SQLSTATE-to-variant mapping can be
564/// unit-tested without constructing an opaque `tokio_postgres::Error`.
565///
566/// Classification is by SQLSTATE class (first two characters):
567/// - `08` connection exception → `NotConnected` (retry, keep in store).
568/// - `28` invalid authorization → `Authentication` (permanent, surfaced).
569/// - `23`/`42` integrity/syntax → `Configuration` (permanent, surfaced).
570/// - `40` transaction rollback (`40001` serialization_failure,
571///   `40P01` deadlock_detected, …) → `Timeout`, a transient/retryable error:
572///   the transaction should be retried rather than dropped.
573/// - anything else → `Request` (treated as permanent/ambiguous).
574fn map_pg_sqlstate(code: &str, detail: &str) -> TargetError {
575    match code.get(..2).unwrap_or("") {
576        "08" => TargetError::NotConnected,
577        "28" => TargetError::Authentication(detail.to_string()),
578        "23" | "42" => TargetError::Configuration(detail.to_string()),
579        "40" => TargetError::Timeout(detail.to_string()),
580        _ => TargetError::Request(detail.to_string()),
581    }
582}
583
584/// Maps a `tokio_postgres::Error` to the proper `TargetError` variant.
585///
586/// Connection-class errors (SQLSTATE 08, closed connection, IO) become
587/// `NotConnected` so the queue store retains the payload for replay.
588/// Schema and constraint problems (SQLSTATE 23, 42) become `Configuration`
589/// so they are surfaced to the operator without endless retry.
590/// Transaction-rollback errors (SQLSTATE class 40, e.g. serialization failure
591/// or deadlock) become `Timeout` so they are retried transiently.
592pub fn map_pg_error(err: &tokio_postgres::Error, context: &str) -> TargetError {
593    if err.is_closed() {
594        return TargetError::NotConnected;
595    }
596    if let Some(db_err) = err.as_db_error() {
597        let detail = format!("{context}: {db_err}");
598        return map_pg_sqlstate(db_err.code().code(), &detail);
599    }
600    TargetError::NotConnected
601}
602
603/// Maps a `deadpool_postgres::PoolError` to the proper `TargetError` variant.
604pub fn map_pool_error(err: deadpool_postgres::PoolError, context: &str) -> TargetError {
605    match err {
606        deadpool_postgres::PoolError::Timeout(_) => TargetError::Timeout(format!("{context}: pool timeout")),
607        deadpool_postgres::PoolError::Backend(pg_err) => map_pg_error(&pg_err, context),
608        deadpool_postgres::PoolError::Closed => TargetError::NotConnected,
609        other => TargetError::Request(format!("{context}: {other}")),
610    }
611}
612
613fn resolve_payload_key(payload: &serde_json::Value, meta: &QueuedPayloadMeta) -> String {
614    payload
615        .get(TARGET_LOG_KEY_FIELD)
616        .and_then(serde_json::Value::as_str)
617        .map(ToOwned::to_owned)
618        .unwrap_or_else(|| {
619            let decoded_object =
620                crate::target::decode_object_name(&meta.object_name).unwrap_or_else(|_| meta.object_name.clone());
621            format!("{}/{}", meta.bucket_name, decoded_object)
622        })
623}
624
625/// PostgreSQL notification target.
626///
627/// Holds a cloneable `deadpool_postgres::Pool` rather than a `Mutex<Option<Pool>>`
628/// so that `clone_box` does not duplicate connection state. The optional
629/// `QueueStore` provides at-least-once delivery semantics consistent with the
630/// other built-in targets.
631///
632/// When `tls_adapter` is `Some`, the target participates in the
633/// coordinated TLS hot-reload system driven by `TlsReloadAdapter`,
634/// and the inline fingerprint check in `send_body` is skipped. When `None`,
635/// the legacy inline fingerprint check is used as a fallback.
636pub struct PostgresTarget<E>
637where
638    E: PluginEvent,
639{
640    id: TargetID,
641    args: PostgresArgs,
642    pool: Arc<parking_lot::Mutex<Pool>>,
643    tls_state: Arc<parking_lot::Mutex<super::TargetTlsState>>,
644    /// When present, the adapter provides coordinator-managed TLS material;
645    /// otherwise the inline fingerprint path is used as a fallback.
646    tls_adapter: Option<TlsReloadAdapter<Pool>>,
647    namespace_sql: String,
648    namespace_delete_sql: String,
649    access_sql: String,
650    store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
651    delivery_counters: Arc<TargetDeliveryCounters>,
652    _phantom: std::marker::PhantomData<E>,
653}
654
655impl<E> PostgresTarget<E>
656where
657    E: PluginEvent,
658{
659    pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
660        Box::new(PostgresTarget::<E> {
661            id: self.id.clone(),
662            args: self.args.clone(),
663            pool: Arc::clone(&self.pool),
664            tls_state: Arc::clone(&self.tls_state),
665            tls_adapter: self.tls_adapter.clone(),
666            namespace_sql: self.namespace_sql.clone(),
667            namespace_delete_sql: self.namespace_delete_sql.clone(),
668            access_sql: self.access_sql.clone(),
669            store: self.store.as_ref().map(|s| s.boxed_clone()),
670            delivery_counters: Arc::clone(&self.delivery_counters),
671            _phantom: std::marker::PhantomData,
672        })
673    }
674
675    #[instrument(skip(args), fields(target_id_as_string = %id))]
676    pub fn new(id: String, args: PostgresArgs) -> Result<Self, TargetError> {
677        args.validate()?;
678        let target_id = TargetID::new(id, ChannelTargetType::Postgres.as_str().to_string());
679        let pool = build_pool(&args)?;
680
681        let queue_store = open_target_queue_store(
682            &args.queue_dir,
683            args.queue_limit,
684            args.target_type,
685            ChannelTargetType::Postgres.as_str(),
686            &target_id,
687            "Failed to open store for PostgreSQL target",
688        )?;
689
690        Ok(Self {
691            id: target_id,
692            namespace_sql: namespace_upsert_sql(&args.schema, &args.table),
693            namespace_delete_sql: namespace_delete_sql(&args.schema, &args.table),
694            access_sql: access_insert_sql(&args.schema, &args.table),
695            args,
696            pool: Arc::new(parking_lot::Mutex::new(pool)),
697            tls_state: Arc::new(parking_lot::Mutex::new(super::TargetTlsState::default())),
698            tls_adapter: None,
699            store: queue_store,
700            delivery_counters: Arc::new(TargetDeliveryCounters::default()),
701            _phantom: std::marker::PhantomData,
702        })
703    }
704
705    /// Sends a serialized event body to PostgreSQL using the configured format.
706    ///
707    /// Identifier validation has already happened in `PostgresArgs::validate()`,
708    /// so `qualified_table` cannot produce a malformed SQL string here.
709    async fn send_body(&self, body: &[u8], event_id: &str, meta: &QueuedPayloadMeta) -> Result<(), TargetError> {
710        // When a TLS reload adapter is attached, it drives pool rebuilds in
711        // the background. The inline per-send fingerprint check is skipped.
712        if self.tls_adapter.is_none() {
713            let next_fingerprint =
714                super::build_target_tls_fingerprint(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
715                    .await?;
716            let tls_changed = {
717                let tls_state_guard = self.tls_state.lock();
718                tls_state_guard.fingerprint.as_ref() != Some(&next_fingerprint)
719            };
720            if tls_changed {
721                let new_pool = build_pool(&self.args)?;
722                *self.pool.lock() = new_pool;
723                self.tls_state.lock().refresh(next_fingerprint);
724            }
725        }
726
727        let pool = self.pool.lock().clone();
728        let client = checkout_client(&pool, "PostgreSQL pool checkout failed").await?;
729
730        let payload: serde_json::Value =
731            serde_json::from_slice(body).map_err(|e| TargetError::Serialization(format!("Failed to parse JSON payload: {e}")))?;
732
733        let key = resolve_payload_key(&payload, meta);
734
735        with_delivery_deadline(POSTGRES_DELIVERY_TIMEOUT, "PostgreSQL delivery", async {
736            match self.args.format {
737                // For the single-row `namespace` format, an object removal must
738                // delete the row rather than UPSERT it, otherwise stale state
739                // lingers in the table after the object is gone.
740                PostgresFormat::Namespace if is_object_removed_event(&meta.event_name) => {
741                    client.execute(&self.namespace_delete_sql, &[&key]).await
742                }
743                PostgresFormat::Namespace => client.execute(&self.namespace_sql, &[&key, &payload]).await,
744                PostgresFormat::Access => {
745                    let event_name_str = meta.event_name.to_string();
746                    let queued_at_ms = meta.queued_at_unix_ms as i64;
747                    client
748                        .execute(&self.access_sql, &[&event_id, &event_name_str, &key, &payload, &queued_at_ms])
749                        .await
750                }
751            }
752            .map_err(|err| map_pg_error(&err, "PostgreSQL insert failed"))
753        })
754        .await?;
755
756        self.delivery_counters.record_success();
757        Ok(())
758    }
759
760    /// Probes the table from `init()`. Failure is non-fatal when a queue is
761    /// configured: events buffer in the store until the schema is fixed.
762    async fn probe_table(&self) -> Result<(), TargetError> {
763        let pool = self.pool.lock().clone();
764        let client = checkout_client(&pool, "PostgreSQL pool checkout failed during init probe").await?;
765        let sql = table_probe_sql(&self.args.schema, &self.args.table);
766        client
767            .execute(sql.as_str(), &[])
768            .await
769            .map_err(|e| map_pg_error(&e, "PostgreSQL table probe failed"))?;
770        Ok(())
771    }
772}
773
774#[async_trait]
775impl<E> ReloadableTargetTls for PostgresTarget<E>
776where
777    E: PluginEvent,
778{
779    type Material = Pool;
780
781    fn tls_input_set(&self) -> TargetTlsInputSet {
782        TargetTlsInputSet {
783            ca_path: self.args.tls_ca.clone(),
784            client_cert_path: self.args.tls_client_cert.clone(),
785            client_key_path: self.args.tls_client_key.clone(),
786            target_label: format!("postgres:{}", self.id.id),
787        }
788    }
789
790    async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
791        build_pool(&self.args)
792    }
793
794    async fn apply_tls_material(
795        &self,
796        _generation: TargetTlsGeneration,
797        material: Arc<Self::Material>,
798        _mode: ReloadApplyMode,
799    ) -> Result<(), TargetError> {
800        *self.pool.lock() = (*material).clone();
801        Ok(())
802    }
803
804    async fn validate_tls_files(&self) -> Result<(), TargetError> {
805        validate_tls_material(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
806    }
807}
808
809#[async_trait]
810impl<E> Target<E> for PostgresTarget<E>
811where
812    E: PluginEvent,
813{
814    fn id(&self) -> TargetID {
815        self.id.clone()
816    }
817
818    async fn is_active(&self) -> Result<bool, TargetError> {
819        if !self.is_enabled() {
820            return Ok(false);
821        }
822
823        match tokio::time::timeout(Duration::from_secs(10), async {
824            let pool = self.pool.lock().clone();
825            let client = checkout_client(&pool, "PostgreSQL pool checkout failed").await?;
826            client
827                .execute("SELECT 1", &[])
828                .await
829                .map_err(|e| map_pg_error(&e, "PostgreSQL liveness probe failed"))?;
830            Ok::<(), TargetError>(())
831        })
832        .await
833        {
834            Ok(Ok(())) => Ok(true),
835            Ok(Err(err)) => Err(err),
836            Err(_) => Err(TargetError::Timeout("PostgreSQL liveness probe timed out after 10s".to_string())),
837        }
838    }
839
840    async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
841        let queued = match build_queued_payload(event.as_ref()) {
842            Ok(queued) => queued,
843            Err(err) => {
844                self.delivery_counters.record_final_failure();
845                return Err(err);
846            }
847        };
848
849        if let Some(store) = &self.store {
850            if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
851                self.delivery_counters.record_final_failure();
852                return Err(e);
853            }
854            Ok(())
855        } else {
856            // No queue: deliver immediately. Fresh UUID acts as the access-format
857            // event_id so retries from the caller produce distinct rows.
858            let event_id = Uuid::new_v4().to_string();
859            if let Err(err) = self.send_body(&queued.body, &event_id, &queued.meta).await {
860                self.delivery_counters.record_final_failure();
861                return Err(err);
862            }
863            Ok(())
864        }
865    }
866
867    async fn send_raw_from_store(&self, key: Key, body: Vec<u8>, meta: QueuedPayloadMeta) -> Result<(), TargetError> {
868        // Use the store key as a stable event_id so replays of the same physical
869        // event are idempotent under the access-format composite PK.
870        let event_id = key.to_string();
871        self.send_body(&body, &event_id, &meta).await
872    }
873
874    async fn close(&self) -> Result<(), TargetError> {
875        self.pool.lock().close();
876        // Adapter cleanup is done by the coordinator; no local state to reset.
877        info!(target_id = %self.id, "PostgreSQL target closed");
878        Ok(())
879    }
880
881    fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
882        self.store.as_deref()
883    }
884
885    fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
886        self.clone_box()
887    }
888
889    async fn init(&self) -> Result<(), TargetError> {
890        if !self.is_enabled() {
891            return Ok(());
892        }
893        match self.probe_table().await {
894            Ok(()) => Ok(()),
895            Err(err) if self.store.is_some() => {
896                warn!(target_id = %self.id, error = %err, "PostgreSQL init probe failed; events will buffer in store");
897                Ok(())
898            }
899            Err(err) => Err(err),
900        }
901    }
902
903    fn is_enabled(&self) -> bool {
904        self.args.enable
905    }
906
907    fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
908        self.delivery_counters.snapshot(
909            self.store.as_deref().map_or(0, |store| store.len() as u64),
910            // Postgres targets record no terminal failures and keep no failed store.
911            0,
912        )
913    }
914
915    fn record_final_failure(&self) {
916        self.delivery_counters.record_final_failure();
917    }
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use crate::target::REDACTED_SECRET;
924
925    fn base_args() -> PostgresArgs {
926        PostgresArgs {
927            enable: true,
928            dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public".to_string(),
929            schema: "public".to_string(),
930            table: "rustfs_events_namespace".to_string(),
931            format: PostgresFormat::Namespace,
932            tls_required: false,
933            tls_ca: String::new(),
934            tls_client_cert: String::new(),
935            tls_client_key: String::new(),
936            queue_dir: String::new(),
937            queue_limit: 100_000,
938            target_type: TargetType::NotifyEvent,
939        }
940    }
941
942    #[test]
943    fn validate_disabled_skips_all_checks() {
944        let args = PostgresArgs {
945            enable: false,
946            dsn_string: String::new(),
947            schema: String::new(),
948            table: String::new(),
949            ..base_args()
950        };
951        assert!(args.validate().is_ok());
952    }
953
954    #[test]
955    fn validate_accepts_base_args() {
956        assert!(base_args().validate().is_ok());
957    }
958
959    #[tokio::test]
960    async fn is_active_returns_false_when_disabled() {
961        let target = PostgresTarget::<String>::new(
962            "postgres:test".to_string(),
963            PostgresArgs {
964                enable: false,
965                dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public".to_string(),
966                ..base_args()
967            },
968        )
969        .expect("disabled target should still construct");
970
971        assert!(!target.is_active().await.expect("disabled target should not probe"));
972    }
973
974    #[test]
975    fn validate_rejects_empty_dsn_string() {
976        let args = PostgresArgs {
977            dsn_string: String::new(),
978            ..base_args()
979        };
980        let err = args.validate().expect_err("empty dsn string should fail");
981        assert!(err.to_string().contains("dsn_string cannot be empty"));
982    }
983
984    #[test]
985    fn validate_rejects_invalid_dsn_string() {
986        let args = PostgresArgs {
987            dsn_string: "postgres://".to_string(),
988            ..base_args()
989        };
990        let err = args.validate().expect_err("invalid dsn should fail");
991        assert!(err.to_string().contains("invalid PostgreSQL dsn_string"));
992    }
993
994    #[test]
995    fn validate_rejects_invalid_schema_identifier() {
996        let args = PostgresArgs {
997            schema: "public; DROP TABLE".to_string(),
998            ..base_args()
999        };
1000        let err = args.validate().expect_err("invalid schema should fail");
1001        assert!(err.to_string().contains("schema"));
1002    }
1003
1004    #[test]
1005    fn validate_rejects_invalid_table_identifier() {
1006        let args = PostgresArgs {
1007            table: "events;".to_string(),
1008            ..base_args()
1009        };
1010        let err = args.validate().expect_err("invalid table should fail");
1011        assert!(err.to_string().contains("table"));
1012    }
1013
1014    #[test]
1015    fn validate_rejects_table_starting_with_digit() {
1016        let args = PostgresArgs {
1017            table: "1events".to_string(),
1018            ..base_args()
1019        };
1020        let err = args.validate().expect_err("digit-leading table should fail");
1021        assert!(err.to_string().contains("table"));
1022    }
1023
1024    #[test]
1025    fn validate_rejects_mtls_without_key() {
1026        let args = PostgresArgs {
1027            tls_client_cert: "/etc/ssl/client.pem".to_string(),
1028            tls_client_key: String::new(),
1029            ..base_args()
1030        };
1031        let err = args.validate().expect_err("missing key should fail");
1032        assert!(err.to_string().contains("must be specified together"));
1033    }
1034
1035    #[test]
1036    fn validate_rejects_relative_queue_dir() {
1037        let args = PostgresArgs {
1038            queue_dir: "relative/path".to_string(),
1039            ..base_args()
1040        };
1041        let err = args.validate().expect_err("relative queue_dir should fail");
1042        assert!(err.to_string().contains("absolute path"));
1043    }
1044
1045    #[test]
1046    fn validate_rejects_relative_tls_ca() {
1047        let args = PostgresArgs {
1048            tls_ca: "ca.pem".to_string(),
1049            ..base_args()
1050        };
1051        let err = args.validate().expect_err("relative tls_ca should fail");
1052        assert!(err.to_string().contains("absolute path"));
1053    }
1054
1055    #[test]
1056    fn parse_format_defaults_to_namespace() {
1057        assert_eq!(parse_postgres_format(None).expect("ok"), PostgresFormat::Namespace);
1058        assert_eq!(parse_postgres_format(Some("")).expect("ok"), PostgresFormat::Namespace);
1059        assert_eq!(parse_postgres_format(Some("  ")).expect("ok"), PostgresFormat::Namespace);
1060    }
1061
1062    #[test]
1063    fn parse_format_accepts_variants() {
1064        assert_eq!(parse_postgres_format(Some("namespace")).expect("ok"), PostgresFormat::Namespace);
1065        assert_eq!(parse_postgres_format(Some("ACCESS")).expect("ok"), PostgresFormat::Access);
1066        assert_eq!(parse_postgres_format(Some("Access")).expect("ok"), PostgresFormat::Access);
1067    }
1068
1069    #[test]
1070    fn parse_format_rejects_unknown() {
1071        let err = parse_postgres_format(Some("structured")).expect_err("unknown format should fail");
1072        assert!(err.to_string().contains("must be 'namespace' or 'access'"));
1073    }
1074
1075    #[test]
1076    fn parse_dsn_extracts_search_path_schema() {
1077        let parsed = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events?search_path=audit,public")
1078            .expect("dsn should parse");
1079        assert_eq!(parsed.host, "localhost");
1080        assert_eq!(parsed.port, 5432);
1081        assert_eq!(parsed.user, "postgres");
1082        assert_eq!(parsed.password.as_deref(), Some("secret"));
1083        assert_eq!(parsed.database, "rustfs_events");
1084        assert_eq!(parsed.schema, "audit");
1085    }
1086
1087    #[test]
1088    fn parse_dsn_defaults_schema_to_public() {
1089        let parsed = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events").expect("dsn should parse");
1090        assert_eq!(parsed.schema, "public");
1091    }
1092
1093    #[test]
1094    fn parse_dsn_rejects_invalid_scheme() {
1095        let err = PostgresDsn::parse("mysql://user:pass@localhost:5432/db").expect_err("scheme should fail");
1096        assert!(err.to_string().contains("scheme must be postgres or postgresql"));
1097    }
1098
1099    #[test]
1100    fn parse_dsn_rejects_invalid_search_path_identifier() {
1101        let err = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public;drop")
1102            .expect_err("invalid search_path should fail");
1103        assert!(err.to_string().contains("schema"));
1104    }
1105
1106    #[test]
1107    fn validate_rejects_schema_mismatch_with_dsn_search_path() {
1108        let args = PostgresArgs {
1109            schema: "public".to_string(),
1110            dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=audit".to_string(),
1111            ..base_args()
1112        };
1113        let err = args.validate().expect_err("schema mismatch should fail");
1114        assert!(err.to_string().contains("schema must match DSN search_path"));
1115    }
1116
1117    #[test]
1118    fn debug_masks_password() {
1119        let args = base_args();
1120        let rendered = format!("{args:?}");
1121        assert!(!rendered.contains("secret"), "password leaked: {rendered}");
1122        assert!(rendered.contains("postgres:***@"));
1123    }
1124
1125    #[test]
1126    fn debug_masks_password_when_empty_shows_blank() {
1127        let args = PostgresArgs {
1128            dsn_string: "postgres://postgres@localhost:5432/rustfs_events?search_path=public".to_string(),
1129            ..base_args()
1130        };
1131        let rendered = format!("{args:?}");
1132        assert!(!rendered.contains(":***@"));
1133    }
1134
1135    #[test]
1136    fn debug_redacts_postgres_dsn_password() {
1137        let dsn = PostgresDsn::parse("postgres://postgres:pg-secret@localhost:5432/rustfs_events?search_path=public")
1138            .expect("valid DSN");
1139
1140        let rendered = format!("{dsn:?}");
1141
1142        assert!(!rendered.contains("pg-secret"));
1143        assert!(rendered.contains(REDACTED_SECRET));
1144        assert!(rendered.contains("rustfs_events"));
1145    }
1146
1147    #[test]
1148    fn redact_postgres_dsn_masks_password_query_parameter() {
1149        let redacted = redact_postgres_dsn("postgres://postgres@localhost:5432/db?search_path=public&password=secret");
1150        assert!(!redacted.contains("secret"));
1151        assert!(redacted.contains("password=%2A%2A%2A") || redacted.contains("password=***"));
1152    }
1153
1154    #[test]
1155    fn qualified_table_double_quotes_both_parts() {
1156        assert_eq!(qualified_table("public", "events"), r#""public"."events""#);
1157        assert_eq!(qualified_table("audit", "rustfs_events"), r#""audit"."rustfs_events""#);
1158    }
1159
1160    #[test]
1161    fn namespace_upsert_uses_on_conflict_update() {
1162        let sql = namespace_upsert_sql("public", "events");
1163        assert!(sql.contains("ON CONFLICT (key) DO UPDATE"));
1164        assert!(sql.contains(r#""public"."events""#));
1165        assert!(sql.contains("$2::jsonb"));
1166    }
1167
1168    #[test]
1169    fn access_insert_uses_event_id_pk_with_on_conflict_do_nothing() {
1170        let sql = access_insert_sql("public", "events_access");
1171        assert!(sql.contains("event_id"));
1172        assert!(sql.contains("ON CONFLICT (event_id) DO NOTHING"));
1173        assert!(sql.contains(r#""public"."events_access""#));
1174        assert!(sql.contains("$4::jsonb"));
1175    }
1176
1177    #[test]
1178    fn namespace_delete_targets_row_by_key() {
1179        let sql = namespace_delete_sql("public", "events");
1180        assert!(sql.starts_with("DELETE FROM"));
1181        assert!(sql.contains(r#""public"."events""#));
1182        assert!(sql.contains("WHERE key = $1"));
1183    }
1184
1185    #[test]
1186    fn is_object_removed_event_matches_all_removed_variants() {
1187        assert!(is_object_removed_event(&EventName::ObjectRemovedDelete));
1188        assert!(is_object_removed_event(&EventName::ObjectRemovedDeleteMarkerCreated));
1189        assert!(is_object_removed_event(&EventName::ObjectRemovedDeleteAllVersions));
1190        assert!(is_object_removed_event(&EventName::ObjectRemovedAll));
1191        assert!(!is_object_removed_event(&EventName::ObjectCreatedPut));
1192        assert!(!is_object_removed_event(&EventName::ObjectAccessedGet));
1193    }
1194
1195    #[test]
1196    fn map_pg_sqlstate_classifies_transaction_rollback_as_transient() {
1197        // 40001 serialization_failure and 40P01 deadlock_detected are transient
1198        // and must be retried, not dropped as permanent failures.
1199        assert!(matches!(map_pg_sqlstate("40001", "ctx: serialization"), TargetError::Timeout(_)));
1200        assert!(matches!(map_pg_sqlstate("40P01", "ctx: deadlock"), TargetError::Timeout(_)));
1201        assert!(matches!(map_pg_sqlstate("40000", "ctx: rollback"), TargetError::Timeout(_)));
1202    }
1203
1204    #[test]
1205    fn map_pg_sqlstate_classifies_connection_and_permanent_errors() {
1206        assert!(matches!(map_pg_sqlstate("08006", "ctx"), TargetError::NotConnected));
1207        assert!(matches!(map_pg_sqlstate("08001", "ctx"), TargetError::NotConnected));
1208        assert!(matches!(map_pg_sqlstate("28P01", "ctx: auth"), TargetError::Authentication(_)));
1209        assert!(matches!(map_pg_sqlstate("23505", "ctx: unique"), TargetError::Configuration(_)));
1210        assert!(matches!(map_pg_sqlstate("42P01", "ctx: undefined_table"), TargetError::Configuration(_)));
1211        // Unknown class stays permanent (ambiguous → surfaced as Request).
1212        assert!(matches!(map_pg_sqlstate("22001", "ctx: data"), TargetError::Request(_)));
1213        assert!(matches!(map_pg_sqlstate("", "ctx: empty"), TargetError::Request(_)));
1214    }
1215
1216    #[test]
1217    fn transient_pg_errors_are_connectivity_errors() {
1218        // Transaction-rollback errors must be treated as connectivity errors so
1219        // the queue store retains the payload for replay instead of dropping it.
1220        assert!(crate::target::is_connectivity_error(&map_pg_sqlstate("40001", "ctx")));
1221        assert!(crate::target::is_connectivity_error(&map_pg_sqlstate("40P01", "ctx")));
1222    }
1223
1224    #[test]
1225    fn table_probe_does_not_select_rows() {
1226        let sql = table_probe_sql("public", "events");
1227        assert!(sql.contains("LIMIT 0"));
1228        assert!(sql.contains(r#""public"."events""#));
1229    }
1230
1231    #[test]
1232    fn validate_pg_identifier_accepts_alphanumerics() {
1233        assert!(validate_pg_identifier("events", "table").is_ok());
1234        assert!(validate_pg_identifier("rustfs_events_v2", "table").is_ok());
1235        assert!(validate_pg_identifier("_underscored", "table").is_ok());
1236    }
1237
1238    #[test]
1239    fn validate_pg_identifier_rejects_dot_and_quote() {
1240        assert!(validate_pg_identifier("public.events", "table").is_err());
1241        assert!(validate_pg_identifier("events\"DROP", "table").is_err());
1242        assert!(validate_pg_identifier("a b", "table").is_err());
1243    }
1244
1245    #[test]
1246    fn resolve_payload_key_prefers_serialized_key_field() {
1247        let payload = serde_json::json!({
1248            "EventName": "s3:ObjectCreated:Put",
1249            "Key": "bucket-a/folder/object.txt",
1250            "Records": []
1251        });
1252        let meta = QueuedPayloadMeta::new(
1253            rustfs_s3_types::EventName::ObjectCreatedPut,
1254            "bucket-a".to_string(),
1255            "fallback%2Fvalue.txt".to_string(),
1256            "application/json",
1257            0,
1258        );
1259
1260        assert_eq!(resolve_payload_key(&payload, &meta), "bucket-a/folder/object.txt");
1261    }
1262
1263    #[test]
1264    fn resolve_payload_key_falls_back_to_decoded_meta_key() {
1265        let payload = serde_json::json!({
1266            "EventName": "s3:ObjectCreated:Put",
1267            "Records": []
1268        });
1269        let meta = QueuedPayloadMeta::new(
1270            rustfs_s3_types::EventName::ObjectCreatedPut,
1271            "bucket-a".to_string(),
1272            "hello+world%2Ftest.txt".to_string(),
1273            "application/json",
1274            0,
1275        );
1276
1277        assert_eq!(resolve_payload_key(&payload, &meta), "bucket-a/hello world/test.txt");
1278    }
1279}