Skip to main content

rustfs_targets/target/
mod.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::arn::TargetID;
16use crate::plugin::PluginEvent;
17use crate::store::{FailedEventStore, Key, QueueStore, Store};
18use crate::{StoreError, TargetError, TargetLog};
19use async_trait::async_trait;
20use rustfs_s3_types::EventName;
21use serde::{Deserialize, Serialize};
22use std::cell::Cell;
23use std::fmt::Formatter;
24use std::future::Future;
25use std::path::PathBuf;
26use std::sync::Arc;
27use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
28use std::thread_local;
29use std::time::{Duration, SystemTime, UNIX_EPOCH};
30use tracing::{debug, warn};
31
32pub mod amqp;
33pub mod kafka;
34pub mod mqtt;
35pub mod mysql;
36pub mod nats;
37pub mod postgres;
38pub mod pulsar;
39pub mod redis;
40pub mod webhook;
41
42#[cfg(test)]
43pub(crate) use crate::runtime::tls::fingerprint::TargetTlsFingerprint as TargetTlsFingerprintState;
44#[cfg(test)]
45pub(crate) use crate::runtime::tls::fingerprint::TargetTlsGeneration;
46pub(crate) use crate::runtime::tls::fingerprint::TargetTlsState;
47pub(crate) use crate::runtime::tls::fingerprint::build_target_tls_fingerprint;
48
49pub(crate) const REDACTED_SECRET: &str = "***redacted***";
50
51pub(crate) fn redacted_secret(value: &str) -> &'static str {
52    if value.is_empty() { "" } else { REDACTED_SECRET }
53}
54
55pub(crate) fn redacted_optional_secret(value: Option<&str>) -> &'static str {
56    value.filter(|secret| !secret.is_empty()).map_or("", |_| REDACTED_SECRET)
57}
58
59/// A read-only snapshot of delivery counters for a target.
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct TargetDeliverySnapshot {
62    pub failed_messages: u64,
63    pub failed_store_length: u64,
64    pub queue_length: u64,
65    pub total_messages: u64,
66}
67
68/// Shared target delivery counters.
69#[derive(Debug, Default)]
70pub struct TargetDeliveryCounters {
71    failed_messages: AtomicU64,
72    total_messages: AtomicU64,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum TargetHealthState {
77    Disabled,
78    Error,
79    Offline,
80    Online,
81}
82
83impl TargetHealthState {
84    pub const fn as_str(self) -> &'static str {
85        match self {
86            Self::Disabled => "disabled",
87            Self::Error => "error",
88            Self::Offline => "offline",
89            Self::Online => "online",
90        }
91    }
92
93    pub const fn status(self) -> &'static str {
94        match self {
95            Self::Online => "online",
96            Self::Disabled | Self::Error | Self::Offline => "offline",
97        }
98    }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TargetHealthReason {
103    AuthenticationFailed,
104    ConfigurationInvalid,
105    ConnectionRefused,
106    Disabled,
107    DnsFailure,
108    HealthCheckFailed,
109    InitializationFailed,
110    NotLoadedInRuntime,
111    Reachable,
112    RequestFailed,
113    TimedOut,
114    TlsFailure,
115    Unreachable,
116}
117
118impl TargetHealthReason {
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            Self::AuthenticationFailed => "authentication_failed",
122            Self::ConfigurationInvalid => "configuration_invalid",
123            Self::ConnectionRefused => "connection_refused",
124            Self::Disabled => "disabled",
125            Self::DnsFailure => "dns_failure",
126            Self::HealthCheckFailed => "health_check_failed",
127            Self::InitializationFailed => "initialization_failed",
128            Self::NotLoadedInRuntime => "not_loaded_in_runtime",
129            Self::Reachable => "reachable",
130            Self::RequestFailed => "request_failed",
131            Self::TimedOut => "timed_out",
132            Self::TlsFailure => "tls_failure",
133            Self::Unreachable => "unreachable",
134        }
135    }
136
137    fn from_target_error(err: &TargetError) -> Self {
138        match err {
139            TargetError::Authentication(_) => Self::AuthenticationFailed,
140            TargetError::Configuration(_) | TargetError::ParseError(_) => Self::ConfigurationInvalid,
141            TargetError::Initialization(_) | TargetError::ServerNotInitialized(_) => Self::InitializationFailed,
142            TargetError::Network(_) | TargetError::NotConnected => Self::Unreachable,
143            TargetError::Request(_) => Self::RequestFailed,
144            TargetError::Timeout(_) => Self::TimedOut,
145            TargetError::Storage(_)
146            | TargetError::JetStreamPublish { .. }
147            | TargetError::Encoding(_)
148            | TargetError::Serialization(_)
149            | TargetError::InvalidARN(_)
150            | TargetError::Unknown(_)
151            | TargetError::Disabled
152            | TargetError::Dropped(_)
153            | TargetError::SaveConfig(_) => Self::HealthCheckFailed,
154        }
155    }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub struct TargetHealth {
160    pub state: TargetHealthState,
161    pub reason: TargetHealthReason,
162}
163
164impl TargetHealth {
165    pub const fn disabled() -> Self {
166        Self {
167            state: TargetHealthState::Disabled,
168            reason: TargetHealthReason::Disabled,
169        }
170    }
171
172    pub const fn error(reason: TargetHealthReason) -> Self {
173        Self {
174            state: TargetHealthState::Error,
175            reason,
176        }
177    }
178
179    pub const fn offline(reason: TargetHealthReason) -> Self {
180        Self {
181            state: TargetHealthState::Offline,
182            reason,
183        }
184    }
185
186    pub const fn online(reason: TargetHealthReason) -> Self {
187        Self {
188            state: TargetHealthState::Online,
189            reason,
190        }
191    }
192}
193
194pub(crate) type BoxedQueuedStore = Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>;
195
196impl TargetDeliveryCounters {
197    #[inline]
198    pub fn record_success(&self) {
199        self.total_messages.fetch_add(1, Ordering::Relaxed);
200    }
201
202    #[inline]
203    pub fn record_final_failure(&self) {
204        self.failed_messages.fetch_add(1, Ordering::Relaxed);
205    }
206
207    #[inline]
208    pub fn snapshot(&self, queue_length: u64, failed_store_length: u64) -> TargetDeliverySnapshot {
209        TargetDeliverySnapshot {
210            failed_messages: self.failed_messages.load(Ordering::Relaxed),
211            failed_store_length,
212            queue_length,
213            total_messages: self.total_messages.load(Ordering::Relaxed),
214        }
215    }
216}
217
218/// Trait for notification targets
219#[async_trait]
220pub trait Target<E>: Send + Sync + 'static
221where
222    E: PluginEvent,
223{
224    /// Returns the ID of the target
225    fn id(&self) -> TargetID;
226
227    /// Returns the name of the target
228    fn name(&self) -> String {
229        self.id().to_string()
230    }
231
232    /// Checks if the target is active and reachable
233    async fn is_active(&self) -> Result<bool, TargetError>;
234
235    /// Returns a credential-free, machine-readable health result.
236    async fn health(&self) -> TargetHealth {
237        if !self.is_enabled() {
238            return TargetHealth::disabled();
239        }
240
241        match self.is_active().await {
242            Ok(true) => TargetHealth::online(TargetHealthReason::Reachable),
243            Ok(false) => TargetHealth::offline(TargetHealthReason::Unreachable),
244            Err(err) => TargetHealth::error(TargetHealthReason::from_target_error(&err)),
245        }
246    }
247
248    /// Saves an event (either sends it immediately or stores it for later).
249    ///
250    /// A target whose [`Self::store`] returns `Some` must only persist the event
251    /// here; network delivery belongs to its replay worker. Runtime lifecycle
252    /// handoff drains these durable enqueues while allowing a direct network
253    /// send to finish against a detached target.
254    async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError>;
255
256    /// Sends an event from the store using the queued raw body and metadata.
257    async fn send_raw_from_store(&self, key: Key, body: Vec<u8>, meta: QueuedPayloadMeta) -> Result<(), TargetError>;
258
259    /// Sends an event from the store.
260    async fn send_from_store(&self, key: Key) -> Result<(), TargetError> {
261        let store = self
262            .store()
263            .ok_or_else(|| TargetError::Configuration("No store configured".to_string()))?;
264
265        let raw = match store.get_raw(&key) {
266            Ok(raw) => raw,
267            Err(StoreError::NotFound) => {
268                // The backing file is missing or empty (a zero-byte file reads as
269                // NotFound). Left in the index it would be "replayed" forever and
270                // permanently occupy a queue slot, eventually rejecting new events
271                // with LimitExceeded. Purge the stale index entry (and any residual
272                // file) before returning.
273                delete_stored_payload(store, &key)?;
274                return Ok(());
275            }
276            Err(err) => return Err(TargetError::Storage(format!("Failed to read queued payload from store: {err}"))),
277        };
278
279        let queued = match QueuedPayload::decode(&raw) {
280            Ok(queued) => queued,
281            Err(err) => {
282                delete_stored_payload(store, &key).map_err(|delete_err| {
283                    TargetError::Storage(format!(
284                        "Failed to delete invalid queued payload {key} after decode error '{err}': {delete_err}"
285                    ))
286                })?;
287                self.record_final_failure();
288                warn!("Dropped invalid queued payload {key}: {err}");
289                return Err(TargetError::Dropped(format!("Dropped invalid queued payload {key}: {err}")));
290            }
291        };
292
293        self.send_raw_from_store(key.clone(), queued.body, queued.meta).await?;
294        delete_stored_payload(store, &key)
295    }
296
297    /// Closes the target and releases resources
298    async fn close(&self) -> Result<(), TargetError>;
299
300    /// Returns the store associated with the target (if any)
301    fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)>;
302
303    /// Returns the failed-events store capability when the target records terminal failures.
304    ///
305    /// The default is no capability, so a target that never parks a terminal entry runs no failed-store
306    /// maintenance and reports zero failed-store depth.
307    fn failed_store(&self) -> Option<&dyn FailedEventStore> {
308        None
309    }
310
311    /// Moves a terminally failed entry to the target's failed-events store, returning true when the entry was handled. A target without a terminal-failure store declines the move and the entry stays live.
312    async fn handle_terminal_failure(
313        &self,
314        _store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
315        _key: &Key,
316        _error: &TargetError,
317        _retry_count: u32,
318    ) -> bool {
319        false
320    }
321
322    /// Returns the type of the target
323    fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync>;
324
325    /// Initialize the target, such as establishing a connection, etc.
326    async fn init(&self) -> Result<(), TargetError> {
327        // The default implementation is empty
328        Ok(())
329    }
330
331    /// Check if the target is enabled
332    fn is_enabled(&self) -> bool;
333
334    /// Returns a read-only delivery snapshot for metrics collection.
335    fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
336        TargetDeliverySnapshot {
337            failed_store_length: self.failed_store().map_or(0, |failed_store| failed_store.failed_len() as u64),
338            queue_length: self.store().map_or(0, |store| store.len() as u64),
339            ..TargetDeliverySnapshot::default()
340        }
341    }
342
343    /// Records a final, non-retryable delivery failure for metrics collection.
344    fn record_final_failure(&self) {}
345}
346
347#[derive(Debug, Serialize, Clone, Deserialize)]
348pub struct EntityTarget<E>
349where
350    E: Send + Sync + 'static + Clone + Serialize,
351{
352    pub object_name: String,
353    pub bucket_name: String,
354    pub event_name: EventName,
355    pub data: E,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct QueuedPayloadMeta {
360    pub event_name: EventName,
361    pub bucket_name: String,
362    pub object_name: String,
363    pub content_type: String,
364    pub queued_at_unix_ms: u64,
365    pub payload_len: usize,
366    /// Stable per-entry deduplication identifier sent as the NATS JetStream Nats-Msg-Id header.
367    /// Empty for every non-JetStream target and for entries queued before JetStream was enabled, so
368    /// it is skipped on serialization and absent stored entries decode to an empty value. This keeps
369    /// the stored bytes identical to entries written without the field.
370    #[serde(default, skip_serializing_if = "String::is_empty")]
371    pub dedup_id: String,
372
373    /// Set only on an entry written to the failed-events store. None on every live-queue entry, so it
374    /// is skipped on serialization and a live entry decodes with no failure metadata, keeping live
375    /// stored bytes identical to entries written without the field.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub failure: Option<FailedEntryMeta>,
378}
379
380/// The error class recorded on a failed-store entry. Only a non-retryable publish error reaches the
381/// failed store, so the class confirms a terminal cause for an operator.
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383pub enum FailedErrorClass {
384    /// A non-retryable publish error. Replaying it without fixing the underlying configuration repeats
385    /// the failure.
386    Terminal,
387}
388
389impl FailedErrorClass {
390    /// Stable lowercase tag used in structured logs and operator tooling.
391    pub fn as_str(&self) -> &'static str {
392        match self {
393            FailedErrorClass::Terminal => "terminal",
394        }
395    }
396}
397
398/// Failure metadata added to a queued payload when it is moved to the failed-events store, carried
399/// inside the existing QueuedPayload meta so the failed entry decodes through the same reader.
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401pub struct FailedEntryMeta {
402    /// The failure class recorded for operator triage. A failed-store entry is always terminal.
403    pub error_class: FailedErrorClass,
404    /// An allowlisted, credential-free summary of the failure for operator diagnosis. Never the raw
405    /// error rendering.
406    pub error_detail: String,
407    /// The stored dedup identifier, recording the id the publish attempted. A failed record is never
408    /// republished.
409    pub nats_msg_id: String,
410    /// The instant the entry entered the failed store, a diagnostic field for operator triage. Expiry
411    /// uses the filesystem modification time, not this value.
412    pub failed_at_unix_ms: u64,
413    /// The replay attempt count recorded at the terminal failure.
414    pub retry_count: u32,
415}
416
417impl QueuedPayloadMeta {
418    pub fn new(
419        event_name: EventName,
420        bucket_name: String,
421        object_name: String,
422        content_type: impl Into<String>,
423        payload_len: usize,
424    ) -> Self {
425        Self {
426            event_name,
427            bucket_name,
428            object_name,
429            content_type: content_type.into(),
430            queued_at_unix_ms: SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64,
431            payload_len,
432            dedup_id: String::new(),
433            failure: None,
434        }
435    }
436
437    pub fn best_effort_preview(&self, body: &[u8], limit: usize) -> String {
438        if limit == 0 || body.is_empty() {
439            return String::new();
440        }
441
442        let slice = &body[..body.len().min(limit)];
443        match std::str::from_utf8(slice) {
444            Ok(text) => {
445                if body.len() > limit {
446                    format!("{text}...")
447                } else {
448                    text.to_string()
449                }
450            }
451            Err(_) => format!("<{} bytes binary>", body.len()),
452        }
453    }
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct QueuedPayload {
458    pub meta: QueuedPayloadMeta,
459    pub body: Vec<u8>,
460}
461
462impl QueuedPayload {
463    const MAGIC: [u8; 4] = *b"RQP1";
464
465    pub fn new(meta: QueuedPayloadMeta, body: Vec<u8>) -> Self {
466        Self { meta, body }
467    }
468
469    pub fn encode(&self) -> Result<Vec<u8>, TargetError> {
470        let meta = serde_json::to_vec(&self.meta)
471            .map_err(|err| TargetError::Serialization(format!("Failed to serialize queued payload metadata: {err}")))?;
472        let meta_len = u32::try_from(meta.len())
473            .map_err(|_| TargetError::Serialization("Queued payload metadata is too large".to_string()))?;
474
475        let mut out = Vec::with_capacity(Self::MAGIC.len() + 4 + meta.len() + self.body.len());
476        out.extend_from_slice(&Self::MAGIC);
477        out.extend_from_slice(&meta_len.to_le_bytes());
478        out.extend_from_slice(&meta);
479        out.extend_from_slice(&self.body);
480        Ok(out)
481    }
482
483    pub fn decode(raw: &[u8]) -> Result<Self, TargetError> {
484        if raw.len() < Self::MAGIC.len() + 4 {
485            return Err(TargetError::Serialization("Queued payload is too short".to_string()));
486        }
487        if raw[..Self::MAGIC.len()] != Self::MAGIC {
488            return Err(TargetError::Serialization("Queued payload magic mismatch".to_string()));
489        }
490
491        let mut meta_len_bytes = [0u8; 4];
492        meta_len_bytes.copy_from_slice(&raw[Self::MAGIC.len()..Self::MAGIC.len() + 4]);
493        let meta_len = u32::from_le_bytes(meta_len_bytes) as usize;
494        let meta_start = Self::MAGIC.len() + 4;
495        let meta_end = meta_start + meta_len;
496
497        if meta_end > raw.len() {
498            return Err(TargetError::Serialization("Queued payload metadata length exceeds input".to_string()));
499        }
500
501        let meta: QueuedPayloadMeta = serde_json::from_slice(&raw[meta_start..meta_end])
502            .map_err(|err| TargetError::Serialization(format!("Failed to deserialize queued payload metadata: {err}")))?;
503        let body = raw[meta_end..].to_vec();
504
505        // Reject torn/truncated writes: the body length recorded at encode time
506        // must match the bytes actually present. Without this, a partially
507        // written file (e.g. a crash mid-write) would decode into a silently
508        // truncated payload and be delivered as if complete.
509        if body.len() != meta.payload_len {
510            return Err(TargetError::Serialization(format!(
511                "Queued payload body length mismatch: header declares {} bytes but {} were present",
512                meta.payload_len,
513                body.len()
514            )));
515        }
516
517        Ok(Self { meta, body })
518    }
519}
520
521/// The `ChannelTargetType` enum represents the different types of channel Target
522/// used in the notification system.
523///
524/// It includes:
525/// - `Amqp`: Represents an AMQP 0-9-1 target for sending notifications to a broker.
526/// - `Webhook`: Sends notifications via HTTP POST requests.
527/// - `Kafka`: Publishes notifications to a Kafka topic.
528/// - `Mqtt`: Publishes notifications via MQTT protocol.
529/// - `MySql`: Writes notifications to a MySQL/TiDB table.
530/// - `Nats`: Publishes notifications to a NATS subject.
531/// - `Postgres`: Writes notifications to a PostgreSQL table (namespace or access format).
532/// - `Pulsar`: Publishes notifications to a Pulsar topic.
533/// - `Redis`: Publishes notifications to a Redis channel (pub/sub).
534///
535/// Each variant has an associated string representation that can be used for serialization
536/// or logging purposes.
537/// The `as_str` method returns the string representation of the target type,
538/// and the `Display` implementation allows for easy formatting of the target type as a string.
539///
540/// Example usage:
541/// ```rust
542/// use rustfs_targets::target::ChannelTargetType;
543///
544/// let target_type = ChannelTargetType::Webhook;
545/// assert_eq!(target_type.as_str(), "webhook");
546/// println!("Target type: {}", target_type);
547/// ```
548pub enum ChannelTargetType {
549    Amqp,
550    Webhook,
551    Kafka,
552    Mqtt,
553    MySql,
554    Nats,
555    Postgres,
556    Pulsar,
557    Redis,
558}
559
560impl ChannelTargetType {
561    pub fn as_str(&self) -> &'static str {
562        match self {
563            ChannelTargetType::Amqp => "amqp",
564            ChannelTargetType::Webhook => "webhook",
565            ChannelTargetType::Kafka => "kafka",
566            ChannelTargetType::Mqtt => "mqtt",
567            ChannelTargetType::MySql => "mysql",
568            ChannelTargetType::Nats => "nats",
569            ChannelTargetType::Postgres => "postgres",
570            ChannelTargetType::Pulsar => "pulsar",
571            ChannelTargetType::Redis => "redis",
572        }
573    }
574}
575
576impl std::fmt::Display for ChannelTargetType {
577    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
578        match self {
579            ChannelTargetType::Amqp => write!(f, "amqp"),
580            ChannelTargetType::Webhook => write!(f, "webhook"),
581            ChannelTargetType::Kafka => write!(f, "kafka"),
582            ChannelTargetType::Mqtt => write!(f, "mqtt"),
583            ChannelTargetType::MySql => write!(f, "mysql"),
584            ChannelTargetType::Nats => write!(f, "nats"),
585            ChannelTargetType::Postgres => write!(f, "postgres"),
586            ChannelTargetType::Pulsar => write!(f, "pulsar"),
587            ChannelTargetType::Redis => write!(f, "redis"),
588        }
589    }
590}
591
592/// `TargetType` enum represents the type of target in the notification system.
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub enum TargetType {
595    AuditLog,
596    NotifyEvent,
597}
598
599impl TargetType {
600    pub fn as_str(&self) -> &'static str {
601        match self {
602            TargetType::AuditLog => "audit_log",
603            TargetType::NotifyEvent => "notify_event",
604        }
605    }
606}
607
608impl std::fmt::Display for TargetType {
609    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
610        match self {
611            TargetType::AuditLog => write!(f, "audit_log"),
612            TargetType::NotifyEvent => write!(f, "notify_event"),
613        }
614    }
615}
616
617/// Stable, deterministic 64-bit FNV-1a hash used only to disambiguate queue
618/// directory names. It must stay identical across restarts and releases so a
619/// target keeps resolving to the same on-disk queue directory, hence a fixed
620/// inline implementation rather than `DefaultHasher` (whose algorithm is not
621/// contractually stable).
622fn fnv1a_hash(bytes: &[u8]) -> u64 {
623    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
624    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
625    let mut hash = FNV_OFFSET;
626    for &byte in bytes {
627        hash ^= byte as u64;
628        hash = hash.wrapping_mul(FNV_PRIME);
629    }
630    hash
631}
632
633/// Maps a target-id component to a filesystem-safe queue directory name.
634///
635/// Path-unsafe characters are replaced with an underscore. That replacement is lossy, so two
636/// distinct ids (for example a/b and a_b) could otherwise collapse to the same directory and
637/// interleave their persisted events. A short hash of the original component is appended whenever any
638/// character was replaced, so distinct ids map to distinct directories.
639///
640/// Ids that are already path-safe are returned unchanged, preserving the on-disk directory layout for
641/// existing deployments.
642pub(crate) fn sanitize_queue_dir_component(component: &str) -> String {
643    let mut sanitized = String::with_capacity(component.len());
644    let mut lossy = false;
645    for ch in component.chars() {
646        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
647            sanitized.push(ch);
648        } else {
649            sanitized.push('_');
650            lossy = true;
651        }
652    }
653
654    if sanitized.is_empty() {
655        // An entirely non-safe id would otherwise collapse to a single underscore. Key it by
656        // the original bytes so distinct ids stay distinct.
657        return format!("_{:016x}", fnv1a_hash(component.as_bytes()));
658    }
659
660    if lossy {
661        // Disambiguate the lossy replacement so different originals cannot alias.
662        return format!("{sanitized}-{:016x}", fnv1a_hash(component.as_bytes()));
663    }
664
665    sanitized
666}
667
668pub(crate) fn queue_store_subdir_name(target_type: &str, target_id: &str) -> String {
669    format!("rustfs-{target_type}-{}", sanitize_queue_dir_component(target_id))
670}
671
672/// Decodes a form-urlencoded object name to its original form.
673///
674/// This function properly handles form-urlencoded strings where spaces are
675/// represented as `+` symbols. It first replaces `+` with spaces, then
676/// performs standard percent-decoding.
677///
678/// # Arguments
679/// * `encoded` - The form-urlencoded string to decode
680///
681/// # Returns
682/// The decoded string, or an error if decoding fails
683///
684/// # Example
685/// ```
686/// use rustfs_targets::target::decode_object_name;
687///
688/// let encoded = "greeting+file+%282%29.csv";
689/// let decoded = decode_object_name(encoded).unwrap();
690/// assert_eq!(decoded, "greeting file (2).csv");
691/// ```
692pub fn decode_object_name(encoded: &str) -> Result<String, TargetError> {
693    let replaced = encoded.replace("+", " ");
694    urlencoding::decode(&replaced)
695        .map(|s| s.into_owned())
696        .map_err(|e| TargetError::Encoding(format!("Failed to decode object key: {e}")))
697}
698
699pub(crate) fn build_queued_payload<E>(event: &EntityTarget<E>) -> Result<QueuedPayload, TargetError>
700where
701    E: PluginEvent,
702{
703    build_queued_payload_with_records(event, vec![event.data.clone()])
704}
705
706pub(crate) fn build_queued_payload_with_records<E, R>(
707    event: &EntityTarget<E>,
708    records: Vec<R>,
709) -> Result<QueuedPayload, TargetError>
710where
711    E: PluginEvent,
712    R: Serialize,
713{
714    let object_name = decode_object_name(&event.object_name)?;
715    let key = format!("{}/{}", event.bucket_name, object_name);
716
717    let log = TargetLog {
718        event_name: event.event_name,
719        key,
720        records,
721    };
722
723    let body = serde_json::to_vec(&log).map_err(|err| TargetError::Serialization(format!("Failed to serialize event: {err}")))?;
724    let meta = QueuedPayloadMeta::new(
725        event.event_name,
726        event.bucket_name.clone(),
727        event.object_name.clone(),
728        "application/json",
729        body.len(),
730    );
731
732    Ok(QueuedPayload::new(meta, body))
733}
734
735pub(crate) fn open_target_queue_store(
736    queue_dir: &str,
737    queue_limit: u64,
738    target_type: TargetType,
739    target_type_label: &str,
740    target_id: &TargetID,
741    open_context: &str,
742) -> Result<Option<BoxedQueuedStore>, TargetError> {
743    let store = open_target_queue_store_typed(queue_dir, queue_limit, target_type, target_type_label, target_id, open_context)?;
744    Ok(store.map(|store| Box::new(store) as BoxedQueuedStore))
745}
746
747thread_local! {
748    static DEFER_QUEUE_STORE_OPEN: Cell<bool> = const { Cell::new(false) };
749}
750
751pub(crate) fn with_deferred_queue_store_open<T>(operation: impl FnOnce() -> T) -> T {
752    struct Reset(bool);
753
754    impl Drop for Reset {
755        fn drop(&mut self) {
756            DEFER_QUEUE_STORE_OPEN.with(|deferred| deferred.set(self.0));
757        }
758    }
759
760    let previous = DEFER_QUEUE_STORE_OPEN.with(|deferred| deferred.replace(true));
761    let _reset = Reset(previous);
762    operation()
763}
764
765/// Opens the queue store and returns the concrete QueueStore, so a target that needs its typed
766/// failed-store capability holds it directly rather than through the type-erased Store handle.
767pub(crate) fn open_target_queue_store_typed(
768    queue_dir: &str,
769    queue_limit: u64,
770    target_type: TargetType,
771    target_type_label: &str,
772    target_id: &TargetID,
773    open_context: &str,
774) -> Result<Option<QueueStore<QueuedPayload>>, TargetError> {
775    if queue_dir.is_empty() {
776        return Ok(None);
777    }
778
779    let queue_dir = PathBuf::from(queue_dir).join(queue_store_subdir_name(target_type_label, &target_id.id));
780    let extension = match target_type {
781        TargetType::AuditLog => rustfs_config::audit::AUDIT_STORE_EXTENSION,
782        TargetType::NotifyEvent => rustfs_config::notify::NOTIFY_STORE_EXTENSION,
783    };
784    let store = QueueStore::<QueuedPayload>::new(queue_dir, queue_limit, extension);
785    if !DEFER_QUEUE_STORE_OPEN.with(Cell::get) {
786        store
787            .open()
788            .map_err(|err| TargetError::Storage(format!("{open_context}: {err}")))?;
789    }
790
791    Ok(Some(store))
792}
793
794pub(crate) fn persist_queued_payload_to_store(
795    store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync),
796    queued: &QueuedPayload,
797) -> Result<(), TargetError> {
798    let encoded = queued
799        .encode()
800        .map_err(|err| TargetError::Storage(format!("Failed to encode queued payload: {err}")))?;
801    store
802        .put_raw(&encoded)
803        .map(|_| ())
804        .map_err(|err| TargetError::Storage(format!("Failed to save event to store: {err}")))
805}
806
807pub(crate) fn is_connectivity_error(err: &TargetError) -> bool {
808    matches!(err, TargetError::NotConnected | TargetError::Timeout(_) | TargetError::Network(_))
809}
810
811/// Applies an absolute deadline to one protocol delivery attempt.
812///
813/// Target clients expose different timeout controls, and several of them only
814/// place a timeout value in the wire request without bounding the local socket
815/// future. Keeping the outer deadline here gives every caller the same typed,
816/// retryable timeout without changing the target-specific error mapping.
817pub(crate) async fn with_delivery_deadline<T, F>(
818    deadline: Duration,
819    operation: &'static str,
820    delivery: F,
821) -> Result<T, TargetError>
822where
823    F: Future<Output = Result<T, TargetError>>,
824{
825    match tokio::time::timeout(deadline, delivery).await {
826        Ok(result) => result,
827        Err(_) => Err(TargetError::Timeout(format!("{operation} timed out after {deadline:?}"))),
828    }
829}
830
831pub(crate) async fn invalidate_cache_on_connectivity_error<F, Fut>(err: &TargetError, invalidate: F)
832where
833    F: FnOnce() -> Fut,
834    Fut: Future<Output = ()>,
835{
836    if is_connectivity_error(err) {
837        invalidate().await;
838    }
839}
840
841pub(crate) fn mark_target_disconnected_on_connectivity_error(connected: &AtomicBool, err: &TargetError) {
842    if is_connectivity_error(err) {
843        connected.store(false, Ordering::SeqCst);
844    }
845}
846
847pub(crate) fn delete_stored_payload(
848    store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
849    key: &Key,
850) -> Result<(), TargetError> {
851    match store.del(key) {
852        Ok(()) | Err(StoreError::NotFound) => Ok(()),
853        Err(err) => Err(TargetError::Storage(format!("Failed to delete event from store: {err}"))),
854    }
855}
856
857/// Upper bound on the characters retained from a classified error so a failed-store entry and its
858/// alarm carry a diagnosable summary without an unbounded message.
859const FAILED_ERROR_DETAIL_MAX_LEN: usize = 256;
860
861/// Fallback label substituted for a JetStreamPublish detail that falls outside the fixed vocabulary.
862const UNRECOGNIZED_DETAIL_LABEL: &str = "unrecognized detail";
863
864/// Restricts a JetStreamPublish detail to the fixed vocabulary at the persistence boundary. A detail
865/// of lowercase letters, digits, space, underscore, and colon passes verbatim, any other content is
866/// replaced with a fixed fallback label.
867fn sanitize_failed_detail(detail: &str) -> &str {
868    let in_vocabulary = !detail.is_empty()
869        && detail.chars().all(|character| {
870            character.is_ascii_lowercase() || character.is_ascii_digit() || matches!(character, ' ' | '_' | ':')
871        });
872    if in_vocabulary { detail } else { UNRECOGNIZED_DETAIL_LABEL }
873}
874
875/// Builds a credential-free diagnostic string for a failed entry from a classified error. The
876/// publish-error detail passes through the persistence-boundary sanitizer, a non-publish error
877/// contributes only its variant category, and any embedded value is redacted. The result is
878/// length-bounded at a character boundary.
879pub(crate) fn build_failed_error_detail(error: &TargetError) -> String {
880    let summary = match error {
881        // The publish detail is sanitized to the fixed vocabulary at the persistence boundary.
882        TargetError::JetStreamPublish { detail, .. } => format!("jetstream_publish: {}", sanitize_failed_detail(detail)),
883        TargetError::Dropped(reason) => format!("dropped: {}", redacted_secret(reason)),
884        // Every other variant carries a free-form message that may name a host, path, or credential.
885        // Only the variant category is recorded, with any embedded value redacted.
886        TargetError::Network(value) => format!("network: {}", redacted_secret(value)),
887        TargetError::Request(value) => format!("request: {}", redacted_secret(value)),
888        TargetError::Timeout(value) => format!("timeout: {}", redacted_secret(value)),
889        TargetError::Storage(value) => format!("storage: {}", redacted_secret(value)),
890        TargetError::Authentication(_) => "authentication".to_string(),
891        TargetError::Configuration(_) => "configuration".to_string(),
892        other => format!("error: {}", redacted_secret(&other_error_category(other))),
893    };
894
895    let mut detail = summary;
896    truncate_to_char_boundary(&mut detail, FAILED_ERROR_DETAIL_MAX_LEN);
897    detail
898}
899
900/// Truncates the string to at most max_len bytes, stepping the cut down to the nearest character
901/// boundary so a multi-byte character straddling the cap is dropped whole rather than split.
902fn truncate_to_char_boundary(value: &mut String, max_len: usize) {
903    if value.len() <= max_len {
904        return;
905    }
906    let mut cut = max_len;
907    while !value.is_char_boundary(cut) {
908        cut -= 1;
909    }
910    value.truncate(cut);
911}
912
913/// Names the category of an otherwise free-form error without revealing its message.
914fn other_error_category(error: &TargetError) -> String {
915    match error {
916        TargetError::Encoding(_) => "encoding".to_string(),
917        TargetError::Serialization(_) => "serialization".to_string(),
918        TargetError::Initialization(_) => "initialization".to_string(),
919        TargetError::Unknown(_) => "unknown".to_string(),
920        _ => "other".to_string(),
921    }
922}
923
924/// Encodes a queued payload as a failed-store entry, extending its meta with the failure fields.
925///
926/// Reuses the QueuedPayload format so the failed entry decodes through the same reader, preserving the
927/// routing meta. The error_detail is the credential-free summary. The nats_msg_id is the resolved
928/// dedup id, so an operator sees the id the server saw even for a pre-enable entry.
929pub(crate) fn encode_failed_entry(
930    mut queued: QueuedPayload,
931    error_class: FailedErrorClass,
932    error: &TargetError,
933    retry_count: u32,
934    resolved_dedup_id: &str,
935) -> Result<Vec<u8>, TargetError> {
936    let failed_at_unix_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
937    queued.meta.failure = Some(FailedEntryMeta {
938        error_class,
939        error_detail: build_failed_error_detail(error),
940        nats_msg_id: resolved_dedup_id.to_string(),
941        failed_at_unix_ms,
942        retry_count,
943    });
944    queued.encode()
945}
946
947/// Ensures a rustls crypto provider is installed before any TLS operation.
948///
949/// Multiple target modules (MySQL, Redis, Postgres, MQTT) need this because
950/// each may be the first to perform a TLS handshake. Idempotent: if a
951/// provider is already registered, returns immediately.
952pub(crate) fn ensure_rustls_provider_installed() {
953    if rustls::crypto::CryptoProvider::get_default().is_some() {
954        return;
955    }
956    if let Err(err) = rustls::crypto::aws_lc_rs::default_provider().install_default() {
957        debug!("rustls provider already installed or unavailable: {err:?}");
958    }
959}
960
961#[cfg(test)]
962pub(crate) mod test_support {
963    use super::{QueuedPayload, QueuedPayloadMeta};
964    use crate::Target;
965    use crate::store::QueueStore;
966    use crate::testkit::MockTarget;
967    use rustfs_s3_types::EventName;
968    use std::path::PathBuf;
969    use std::sync::Arc;
970    use uuid::Uuid;
971
972    /// A minimal target for failed-store move tests: every delivery method succeeds, no store is
973    /// attached, and final failures land on the mock's shared counter.
974    pub(crate) fn move_test_target() -> Arc<dyn Target<String> + Send + Sync> {
975        Arc::new(MockTarget::new("target-a", "nats"))
976    }
977
978    /// Like [`move_test_target`], but the given store backs both the store and failed-store
979    /// accessors, matching a target whose live queue also parks terminal failures.
980    pub(crate) fn move_test_target_with_store(store: Arc<QueueStore<QueuedPayload>>) -> Arc<dyn Target<String> + Send + Sync> {
981        Arc::new(
982            MockTarget::new("target-a", "nats")
983                .with_store(store.clone())
984                .with_failed_store(store),
985        )
986    }
987
988    pub(crate) fn failed_store_dir(name: &str) -> PathBuf {
989        std::env::temp_dir().join(format!("rustfs-failed-{name}-{}", Uuid::new_v4()))
990    }
991
992    pub(crate) fn sample_queued(dedup_id: &str) -> QueuedPayload {
993        let mut meta = QueuedPayloadMeta::new(
994            EventName::ObjectCreatedPut,
995            "bucket-a".to_string(),
996            "obj.txt".to_string(),
997            "application/json",
998            7,
999        );
1000        meta.dedup_id = dedup_id.to_string();
1001        QueuedPayload::new(meta, br#"{"x":1}"#.to_vec())
1002    }
1003}
1004
1005#[cfg(test)]
1006mod tls_state_tests {
1007    use super::{TargetTlsFingerprintState, TargetTlsGeneration, TargetTlsState};
1008
1009    #[test]
1010    fn refresh_increments_generation_only_when_fingerprint_changes() {
1011        let mut state = TargetTlsState::default();
1012        let first = TargetTlsFingerprintState {
1013            ca_sha256: Some([1; 32]),
1014            client_cert_sha256: None,
1015            client_key_sha256: None,
1016        };
1017        let second = TargetTlsFingerprintState {
1018            ca_sha256: Some([2; 32]),
1019            client_cert_sha256: None,
1020            client_key_sha256: None,
1021        };
1022
1023        assert!(state.refresh(first.clone()));
1024        assert_eq!(state.generation, TargetTlsGeneration(1));
1025        assert!(!state.refresh(first));
1026        assert_eq!(state.generation, TargetTlsGeneration(1));
1027        assert!(state.refresh(second));
1028        assert_eq!(state.generation, TargetTlsGeneration(2));
1029    }
1030
1031    #[test]
1032    fn reset_clears_generation_and_fingerprint() {
1033        let mut state = TargetTlsState {
1034            generation: TargetTlsGeneration(5),
1035            fingerprint: Some(TargetTlsFingerprintState {
1036                ca_sha256: Some([9; 32]),
1037                client_cert_sha256: None,
1038                client_key_sha256: None,
1039            }),
1040        };
1041
1042        state.reset();
1043        assert_eq!(state, TargetTlsState::default());
1044    }
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050    use std::fs;
1051    use std::sync::Mutex;
1052    use uuid::Uuid;
1053
1054    #[derive(Clone)]
1055    struct MockQueuedStore {
1056        fail_put_raw: bool,
1057        writes: Arc<Mutex<Vec<Vec<u8>>>>,
1058    }
1059
1060    impl MockQueuedStore {
1061        fn new(fail_put_raw: bool) -> Self {
1062            Self {
1063                fail_put_raw,
1064                writes: Arc::new(Mutex::new(Vec::new())),
1065            }
1066        }
1067    }
1068
1069    impl Store<QueuedPayload> for MockQueuedStore {
1070        type Error = StoreError;
1071        type Key = Key;
1072
1073        fn open(&self) -> Result<(), Self::Error> {
1074            Ok(())
1075        }
1076
1077        fn put(&self, _item: Arc<QueuedPayload>) -> Result<Self::Key, Self::Error> {
1078            Err(StoreError::Internal("not implemented in mock".to_string()))
1079        }
1080
1081        fn put_multiple(&self, _items: Vec<QueuedPayload>) -> Result<Self::Key, Self::Error> {
1082            Err(StoreError::Internal("not implemented in mock".to_string()))
1083        }
1084
1085        fn put_raw(&self, data: &[u8]) -> Result<Self::Key, Self::Error> {
1086            if self.fail_put_raw {
1087                return Err(StoreError::Internal("mock put_raw failed".to_string()));
1088            }
1089            self.writes.lock().expect("mock writes lock poisoned").push(data.to_vec());
1090            Ok(Key {
1091                name: "mock".to_string(),
1092                extension: ".json".to_string(),
1093                item_count: 1,
1094                compress: false,
1095            })
1096        }
1097
1098        fn get(&self, _key: &Self::Key) -> Result<QueuedPayload, Self::Error> {
1099            Err(StoreError::Internal("not implemented in mock".to_string()))
1100        }
1101
1102        fn get_multiple(&self, _key: &Self::Key) -> Result<Vec<QueuedPayload>, Self::Error> {
1103            Err(StoreError::Internal("not implemented in mock".to_string()))
1104        }
1105
1106        fn get_raw(&self, _key: &Self::Key) -> Result<Vec<u8>, Self::Error> {
1107            Err(StoreError::Internal("not implemented in mock".to_string()))
1108        }
1109
1110        fn del(&self, _key: &Self::Key) -> Result<(), Self::Error> {
1111            Err(StoreError::Internal("not implemented in mock".to_string()))
1112        }
1113
1114        fn delete(&self) -> Result<(), Self::Error> {
1115            Err(StoreError::Internal("not implemented in mock".to_string()))
1116        }
1117
1118        fn list(&self) -> Vec<Self::Key> {
1119            Vec::new()
1120        }
1121
1122        fn len(&self) -> usize {
1123            0
1124        }
1125
1126        fn is_empty(&self) -> bool {
1127            true
1128        }
1129
1130        fn boxed_clone(&self) -> Box<dyn Store<QueuedPayload, Error = Self::Error, Key = Self::Key> + Send + Sync> {
1131            Box::new(self.clone())
1132        }
1133    }
1134
1135    #[test]
1136    fn channel_target_type_amqp_uses_runtime_name() {
1137        assert_eq!(ChannelTargetType::Amqp.as_str(), "amqp");
1138        assert_eq!(ChannelTargetType::Amqp.to_string(), "amqp");
1139    }
1140
1141    #[test]
1142    fn queued_payload_meta_omits_empty_dedup_id_on_serialization() {
1143        let meta = QueuedPayloadMeta::new(
1144            EventName::ObjectCreatedPut,
1145            "bucket-a".to_string(),
1146            "obj.txt".to_string(),
1147            "application/json",
1148            7,
1149        );
1150        assert!(meta.dedup_id.is_empty());
1151
1152        let json = serde_json::to_string(&meta).unwrap();
1153        assert!(
1154            !json.contains("dedup_id"),
1155            "an empty dedup id is skipped so stored bytes match the pre-feature format"
1156        );
1157
1158        // An entry written without the field decodes to an empty dedup id.
1159        let decoded: QueuedPayloadMeta = serde_json::from_str(&json).unwrap();
1160        assert!(decoded.dedup_id.is_empty());
1161    }
1162
1163    #[test]
1164    fn queued_payload_meta_round_trips_a_populated_dedup_id() {
1165        let mut meta = QueuedPayloadMeta::new(
1166            EventName::ObjectCreatedPut,
1167            "bucket-a".to_string(),
1168            "obj.txt".to_string(),
1169            "application/json",
1170            7,
1171        );
1172        meta.dedup_id = "minted-id".to_string();
1173
1174        let json = serde_json::to_string(&meta).unwrap();
1175        assert!(json.contains("dedup_id"));
1176        let decoded: QueuedPayloadMeta = serde_json::from_str(&json).unwrap();
1177        assert_eq!(decoded.dedup_id, "minted-id");
1178    }
1179
1180    #[test]
1181    fn queued_payload_round_trips_meta_and_body() {
1182        let body = br#"{"ok":true}"#.to_vec();
1183        let meta = QueuedPayloadMeta::new(
1184            EventName::ObjectCreatedPut,
1185            "bucket-a".to_string(),
1186            "folder/object.txt".to_string(),
1187            "application/json",
1188            body.len(),
1189        );
1190        let payload = QueuedPayload::new(meta.clone(), body);
1191
1192        let encoded = payload.encode().unwrap();
1193        let decoded = QueuedPayload::decode(&encoded).unwrap();
1194
1195        assert_eq!(decoded.meta.event_name, meta.event_name);
1196        assert_eq!(decoded.meta.bucket_name, meta.bucket_name);
1197        assert_eq!(decoded.meta.object_name, meta.object_name);
1198        assert_eq!(decoded.meta.content_type, meta.content_type);
1199        assert_eq!(decoded.body, br#"{"ok":true}"#);
1200    }
1201
1202    #[test]
1203    fn build_queued_payload_uses_event_data_shape() {
1204        let event = EntityTarget {
1205            object_name: "greeting+file+%282%29.csv".to_string(),
1206            bucket_name: "bucket-a".to_string(),
1207            event_name: EventName::ObjectCreatedPut,
1208            data: "payload-data".to_string(),
1209        };
1210
1211        let payload = build_queued_payload(&event).unwrap();
1212        let value: serde_json::Value = serde_json::from_slice(&payload.body).unwrap();
1213
1214        assert_eq!(value["Key"], "bucket-a/greeting file (2).csv");
1215        assert_eq!(value["Records"][0], "payload-data");
1216    }
1217
1218    #[test]
1219    fn build_queued_payload_with_records_preserves_custom_record_shape() {
1220        let event = EntityTarget {
1221            object_name: "object.txt".to_string(),
1222            bucket_name: "bucket-a".to_string(),
1223            event_name: EventName::ObjectCreatedPut,
1224            data: "ignored".to_string(),
1225        };
1226
1227        let payload = build_queued_payload_with_records(&event, vec![event.clone()]).unwrap();
1228        let value: serde_json::Value = serde_json::from_slice(&payload.body).unwrap();
1229
1230        assert_eq!(value["Records"][0]["bucket_name"], "bucket-a");
1231        assert_eq!(value["Records"][0]["object_name"], "object.txt");
1232        assert_eq!(value["Records"][0]["data"], "ignored");
1233    }
1234
1235    #[test]
1236    fn open_target_queue_store_returns_none_when_queue_dir_empty() {
1237        let target_id = TargetID::new("target-a".to_string(), ChannelTargetType::Webhook.as_str().to_string());
1238        let store = open_target_queue_store(
1239            "",
1240            100,
1241            TargetType::NotifyEvent,
1242            ChannelTargetType::Webhook.as_str(),
1243            &target_id,
1244            "open failed",
1245        )
1246        .unwrap();
1247        assert!(store.is_none());
1248    }
1249
1250    #[test]
1251    fn open_target_queue_store_adds_context_on_open_error() {
1252        let base = std::env::temp_dir().join(format!("rustfs-target-store-file-{}", Uuid::new_v4()));
1253        fs::write(&base, b"not-a-directory").expect("failed to create file base");
1254        let target_id = TargetID::new("target-a".to_string(), ChannelTargetType::Kafka.as_str().to_string());
1255
1256        let result = open_target_queue_store(
1257            base.to_str().unwrap(),
1258            100,
1259            TargetType::NotifyEvent,
1260            ChannelTargetType::Kafka.as_str(),
1261            &target_id,
1262            "custom open context",
1263        );
1264
1265        match result {
1266            Ok(_) => panic!("expected open_target_queue_store to fail on file base path"),
1267            Err(err) => assert!(err.to_string().contains("custom open context")),
1268        }
1269        let _ = fs::remove_file(base);
1270    }
1271
1272    #[test]
1273    fn deferred_queue_store_creation_does_not_touch_the_filesystem() {
1274        let base = std::env::temp_dir().join(format!("rustfs-target-store-deferred-{}", Uuid::new_v4()));
1275        fs::write(&base, b"not-a-directory").expect("failed to create file base");
1276        let target_id = TargetID::new("target-a".to_string(), ChannelTargetType::Kafka.as_str().to_string());
1277
1278        let store = with_deferred_queue_store_open(|| {
1279            open_target_queue_store(
1280                base.to_str().unwrap(),
1281                100,
1282                TargetType::NotifyEvent,
1283                ChannelTargetType::Kafka.as_str(),
1284                &target_id,
1285                "deferred open",
1286            )
1287        })
1288        .expect("deferred construction must not open the queue directory")
1289        .expect("non-empty queue directory should create a dormant store");
1290
1291        assert!(store.open().is_err(), "the invalid path must fail when handoff explicitly opens it");
1292        let _ = fs::remove_file(base);
1293    }
1294
1295    #[test]
1296    fn persist_queued_payload_to_store_writes_encoded_payload() {
1297        let store = MockQueuedStore::new(false);
1298        let meta = QueuedPayloadMeta::new(
1299            EventName::ObjectCreatedPut,
1300            "bucket-a".to_string(),
1301            "obj.txt".to_string(),
1302            "application/json",
1303            7,
1304        );
1305        let queued = QueuedPayload::new(meta, br#"{"x":1}"#.to_vec());
1306
1307        persist_queued_payload_to_store(&store, &queued).unwrap();
1308
1309        let writes = store.writes.lock().expect("mock writes lock poisoned");
1310        assert_eq!(writes.len(), 1);
1311        let decoded = QueuedPayload::decode(&writes[0]).unwrap();
1312        assert_eq!(decoded.body, br#"{"x":1}"#);
1313    }
1314
1315    #[test]
1316    fn persist_queued_payload_to_store_maps_store_error() {
1317        let store = MockQueuedStore::new(true);
1318        let meta = QueuedPayloadMeta::new(
1319            EventName::ObjectCreatedPut,
1320            "bucket-a".to_string(),
1321            "obj.txt".to_string(),
1322            "application/json",
1323            7,
1324        );
1325        let queued = QueuedPayload::new(meta, br#"{"x":1}"#.to_vec());
1326
1327        let err = persist_queued_payload_to_store(&store, &queued).expect_err("expected put_raw failure");
1328        assert!(err.to_string().contains("Failed to save event to store"));
1329    }
1330
1331    #[test]
1332    fn is_connectivity_error_classifies_target_errors() {
1333        assert!(is_connectivity_error(&TargetError::NotConnected));
1334        assert!(is_connectivity_error(&TargetError::Timeout("timeout".to_string())));
1335        assert!(is_connectivity_error(&TargetError::Network("network".to_string())));
1336        assert!(!is_connectivity_error(&TargetError::Storage("storage".to_string())));
1337        assert!(!is_connectivity_error(&TargetError::Serialization("serialization".to_string())));
1338    }
1339
1340    #[tokio::test(start_paused = true)]
1341    async fn delivery_deadline_cuts_off_a_stalled_protocol_operation() {
1342        let error = with_delivery_deadline(
1343            Duration::from_secs(30),
1344            "test delivery",
1345            std::future::pending::<Result<(), TargetError>>(),
1346        )
1347        .await
1348        .expect_err("a stalled delivery must hit its hard deadline");
1349
1350        assert!(matches!(error, TargetError::Timeout(message) if message == "test delivery timed out after 30s"));
1351    }
1352
1353    #[tokio::test]
1354    async fn invalidate_cache_on_connectivity_error_only_runs_for_connectivity_failures() {
1355        let marker = Arc::new(AtomicBool::new(false));
1356        invalidate_cache_on_connectivity_error(&TargetError::NotConnected, {
1357            let marker = Arc::clone(&marker);
1358            move || async move {
1359                marker.store(true, Ordering::SeqCst);
1360            }
1361        })
1362        .await;
1363        assert!(marker.load(Ordering::SeqCst));
1364
1365        marker.store(false, Ordering::SeqCst);
1366        invalidate_cache_on_connectivity_error(&TargetError::Request("request failed".to_string()), {
1367            let marker = Arc::clone(&marker);
1368            move || async move {
1369                marker.store(true, Ordering::SeqCst);
1370            }
1371        })
1372        .await;
1373        assert!(!marker.load(Ordering::SeqCst));
1374    }
1375
1376    #[test]
1377    fn mark_target_disconnected_on_connectivity_error_only_marks_connectivity_failures() {
1378        let connected = AtomicBool::new(true);
1379        mark_target_disconnected_on_connectivity_error(&connected, &TargetError::Timeout("timeout".to_string()));
1380        assert!(!connected.load(Ordering::SeqCst));
1381
1382        connected.store(true, Ordering::SeqCst);
1383        mark_target_disconnected_on_connectivity_error(&connected, &TargetError::Request("request failed".to_string()));
1384        assert!(connected.load(Ordering::SeqCst));
1385    }
1386
1387    #[test]
1388    fn queued_payload_decode_rejects_invalid_magic() {
1389        let err = QueuedPayload::decode(b"bad-payload").unwrap_err();
1390        assert!(err.to_string().contains("magic") || err.to_string().contains("short"));
1391    }
1392
1393    #[test]
1394    fn sanitize_queue_dir_component_replaces_non_path_safe_characters() {
1395        let sanitized = sanitize_queue_dir_component("tenant:alpha/beta\\gamma?*");
1396        // The readable, path-safe prefix is preserved, followed by a disambiguating
1397        // hash suffix because the replacement was lossy.
1398        assert!(
1399            sanitized.starts_with("tenant_alpha_beta_gamma__-"),
1400            "unexpected sanitized value: {sanitized}"
1401        );
1402        // Deterministic across calls (must be stable across restarts).
1403        assert_eq!(sanitized, sanitize_queue_dir_component("tenant:alpha/beta\\gamma?*"));
1404    }
1405
1406    #[test]
1407    fn sanitize_queue_dir_component_preserves_path_safe_ids() {
1408        // Path-safe ids are returned unchanged so existing on-disk queue
1409        // directories keep resolving (no migration on upgrade).
1410        assert_eq!(sanitize_queue_dir_component("plain-id_1.2"), "plain-id_1.2");
1411    }
1412
1413    #[test]
1414    fn sanitize_queue_dir_component_disambiguates_colliding_ids() {
1415        // Two distinct ids that used to collapse onto the same directory must now
1416        // map to different directories.
1417        let a = sanitize_queue_dir_component("a/b");
1418        let b = sanitize_queue_dir_component("a_b");
1419        assert_ne!(a, b, "distinct ids must not share a queue directory");
1420    }
1421
1422    #[test]
1423    fn queue_store_subdir_name_sanitizes_target_id() {
1424        let dir = queue_store_subdir_name("redis", "tenant:alpha");
1425        assert!(dir.starts_with("rustfs-redis-tenant_alpha-"), "unexpected subdir: {dir}");
1426    }
1427
1428    #[tokio::test]
1429    async fn send_from_store_purges_missing_or_empty_entry() {
1430        let dir = std::env::temp_dir().join(format!("rustfs-send-from-store-{}", Uuid::new_v4()));
1431        let store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 8, ".event", false);
1432        store.open().unwrap();
1433
1434        // Enqueue a valid payload, then truncate its backing file to zero bytes to
1435        // simulate a torn write: read_file now reports NotFound while the index
1436        // still counts the entry.
1437        let meta = QueuedPayloadMeta::new(
1438            EventName::ObjectCreatedPut,
1439            "bucket-a".to_string(),
1440            "obj.txt".to_string(),
1441            "application/json",
1442            7,
1443        );
1444        let encoded = QueuedPayload::new(meta, br#"{"x":1}"#.to_vec()).encode().unwrap();
1445        let key = store.put_raw(&encoded).unwrap();
1446        assert_eq!(store.len(), 1);
1447
1448        let event_file = std::fs::read_dir(&dir)
1449            .unwrap()
1450            .filter_map(|e| e.ok())
1451            .map(|e| e.path())
1452            .find(|p| p.is_file())
1453            .expect("event file should exist");
1454        std::fs::write(&event_file, b"").unwrap();
1455
1456        // The default send_from_store implementation is under test here, so the mock must not
1457        // override it; it only supplies the backing store.
1458        let target: Box<dyn Target<String> + Send + Sync> =
1459            Box::new(crate::testkit::MockTarget::new("primary", "webhook").with_store(Arc::new(store.clone())));
1460
1461        // A NotFound/empty entry must be purged (index + file) rather than
1462        // silently skipped and replayed forever.
1463        target.send_from_store(key).await.unwrap();
1464        assert_eq!(store.len(), 0, "stale entry must be removed from the index");
1465
1466        let _ = store.delete();
1467    }
1468
1469    #[test]
1470    fn queued_payload_decode_rejects_body_length_mismatch() {
1471        let meta = QueuedPayloadMeta::new(
1472            EventName::ObjectCreatedPut,
1473            "bucket-a".to_string(),
1474            "obj.txt".to_string(),
1475            "application/json",
1476            11,
1477        );
1478        let payload = QueuedPayload::new(meta, br#"{"ok":true}"#.to_vec());
1479        let mut encoded = payload.encode().unwrap();
1480
1481        // Drop the final body byte, simulating a torn/truncated write. The header
1482        // still declares the original payload_len, so decode must reject it rather
1483        // than hand back a silently truncated body.
1484        encoded.pop();
1485        let err = QueuedPayload::decode(&encoded).unwrap_err();
1486        assert!(err.to_string().contains("body length mismatch"), "unexpected error: {err}");
1487    }
1488
1489    use super::test_support::sample_queued;
1490
1491    // The error_detail recorded on a failed entry is built from an allowlist of error categories and
1492    // the fixed publish-error vocabulary, never the raw error rendering, so a credential embedded in
1493    // a free-form error message is absent from the detail.
1494    #[test]
1495    fn build_failed_error_detail_redacts_credential_bearing_messages() {
1496        let secret = "nats://user:supersecret@broker:4222";
1497        let cases = [
1498            TargetError::Network(secret.to_string()),
1499            TargetError::Request(secret.to_string()),
1500            TargetError::Timeout(secret.to_string()),
1501            TargetError::Storage(secret.to_string()),
1502            TargetError::Authentication(secret.to_string()),
1503            TargetError::Configuration(secret.to_string()),
1504            TargetError::Dropped(secret.to_string()),
1505            TargetError::Unknown(secret.to_string()),
1506        ];
1507        for error in cases {
1508            let detail = build_failed_error_detail(&error);
1509            assert!(!detail.contains("supersecret"), "detail leaked a credential: {detail}");
1510            assert!(!detail.contains("broker:4222"), "detail leaked a connection string: {detail}");
1511        }
1512    }
1513
1514    // The publish-error classifier sets the detail from its fixed vocabulary of kind labels and
1515    // numeric codes, so the failed-entry detail names the cause without leaking a server-side
1516    // message.
1517    #[test]
1518    fn build_failed_error_detail_uses_the_classified_publish_kind() {
1519        let error = TargetError::JetStreamPublish {
1520            retryable: false,
1521            detail: "max payload exceeded".to_string(),
1522        };
1523        let detail = build_failed_error_detail(&error);
1524        assert_eq!(detail, "jetstream_publish: max payload exceeded");
1525    }
1526
1527    // The persistence-boundary sanitizer replaces a JetStreamPublish detail that carries anything
1528    // outside the fixed vocabulary with the fallback label, while a vocabulary detail passes through
1529    // verbatim.
1530    #[test]
1531    fn build_failed_error_detail_sanitizes_a_hostile_jetstream_detail() {
1532        let hostile = TargetError::JetStreamPublish {
1533            retryable: false,
1534            detail: "Boom! nats://user:pass@host/DROP".to_string(),
1535        };
1536        assert_eq!(build_failed_error_detail(&hostile), "jetstream_publish: unrecognized detail");
1537
1538        let vocabulary = TargetError::JetStreamPublish {
1539            retryable: false,
1540            detail: "wrong last sequence".to_string(),
1541        };
1542        assert_eq!(build_failed_error_detail(&vocabulary), "jetstream_publish: wrong last sequence");
1543    }
1544
1545    // A summary whose cap lands inside a multi-byte character truncates at the preceding character
1546    // boundary instead of panicking on a split character. A three-byte character does not divide
1547    // the 256-byte cap, so one character straddles the cap by construction.
1548    #[test]
1549    fn truncate_to_char_boundary_handles_multi_byte_characters_at_the_cap() {
1550        let mut value = "\u{4e2d}".repeat(FAILED_ERROR_DETAIL_MAX_LEN);
1551        assert!(!value.is_char_boundary(FAILED_ERROR_DETAIL_MAX_LEN), "a character straddles the cap");
1552        truncate_to_char_boundary(&mut value, FAILED_ERROR_DETAIL_MAX_LEN);
1553        assert!(value.len() <= FAILED_ERROR_DETAIL_MAX_LEN, "the result stays within the cap");
1554        assert!(value.is_char_boundary(value.len()), "the result ends on a character boundary");
1555    }
1556
1557    // A terminal move writes a failed entry carrying the full failure meta extension and preserves the
1558    // original routing metadata and dedup id through the reused QueuedPayload format.
1559    #[test]
1560    fn encode_failed_entry_carries_full_failure_meta() {
1561        let queued = sample_queued("minted-id");
1562        let error = TargetError::JetStreamPublish {
1563            retryable: false,
1564            detail: "wrong last sequence".to_string(),
1565        };
1566        let encoded = encode_failed_entry(queued, FailedErrorClass::Terminal, &error, 0, "minted-id").unwrap();
1567        let decoded = QueuedPayload::decode(&encoded).unwrap();
1568
1569        let failure = decoded.meta.failure.expect("a failed entry carries failure meta");
1570        assert_eq!(failure.error_class, FailedErrorClass::Terminal);
1571        assert_eq!(failure.error_detail, "jetstream_publish: wrong last sequence");
1572        assert_eq!(failure.nats_msg_id, "minted-id");
1573        assert_eq!(failure.retry_count, 0);
1574        assert!(failure.failed_at_unix_ms > 0);
1575        // The original routing metadata survives the move.
1576        assert_eq!(decoded.meta.bucket_name, "bucket-a");
1577        assert_eq!(decoded.meta.object_name, "obj.txt");
1578        assert_eq!(decoded.body, br#"{"x":1}"#);
1579    }
1580}