1use 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#[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#[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#[async_trait]
220pub trait Target<E>: Send + Sync + 'static
221where
222 E: PluginEvent,
223{
224 fn id(&self) -> TargetID;
226
227 fn name(&self) -> String {
229 self.id().to_string()
230 }
231
232 async fn is_active(&self) -> Result<bool, TargetError>;
234
235 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 async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError>;
255
256 async fn send_raw_from_store(&self, key: Key, body: Vec<u8>, meta: QueuedPayloadMeta) -> Result<(), TargetError>;
258
259 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 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 async fn close(&self) -> Result<(), TargetError>;
299
300 fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)>;
302
303 fn failed_store(&self) -> Option<&dyn FailedEventStore> {
308 None
309 }
310
311 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 fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync>;
324
325 async fn init(&self) -> Result<(), TargetError> {
327 Ok(())
329 }
330
331 fn is_enabled(&self) -> bool;
333
334 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 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 #[serde(default, skip_serializing_if = "String::is_empty")]
371 pub dedup_id: String,
372
373 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub failure: Option<FailedEntryMeta>,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383pub enum FailedErrorClass {
384 Terminal,
387}
388
389impl FailedErrorClass {
390 pub fn as_str(&self) -> &'static str {
392 match self {
393 FailedErrorClass::Terminal => "terminal",
394 }
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401pub struct FailedEntryMeta {
402 pub error_class: FailedErrorClass,
404 pub error_detail: String,
407 pub nats_msg_id: String,
410 pub failed_at_unix_ms: u64,
413 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 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
521pub 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#[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
617fn 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
633pub(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 return format!("_{:016x}", fnv1a_hash(component.as_bytes()));
658 }
659
660 if lossy {
661 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
672pub 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
765pub(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
811pub(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
857const FAILED_ERROR_DETAIL_MAX_LEN: usize = 256;
860
861const UNRECOGNIZED_DETAIL_LABEL: &str = "unrecognized detail";
863
864fn 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
875pub(crate) fn build_failed_error_detail(error: &TargetError) -> String {
880 let summary = match error {
881 TargetError::JetStreamPublish { detail, .. } => format!("jetstream_publish: {}", sanitize_failed_detail(detail)),
883 TargetError::Dropped(reason) => format!("dropped: {}", redacted_secret(reason)),
884 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
900fn 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
913fn 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
924pub(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
947pub(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 pub(crate) fn move_test_target() -> Arc<dyn Target<String> + Send + Sync> {
975 Arc::new(MockTarget::new("target-a", "nats"))
976 }
977
978 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 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 assert!(
1399 sanitized.starts_with("tenant_alpha_beta_gamma__-"),
1400 "unexpected sanitized value: {sanitized}"
1401 );
1402 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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}