Skip to main content

rustfs_targets/target/
pulsar.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::plugin::PluginEvent;
16use crate::{
17    StoreError, Target,
18    arn::TargetID,
19    error::TargetError,
20    runtime::tls::{
21        ReloadableTargetTls, TargetTlsGeneration, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode,
22        validate_tls_material,
23    },
24    store::{Key, Store},
25    target::{
26        ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
27        TargetTlsState, TargetType, build_queued_payload_with_records, build_target_tls_fingerprint, is_connectivity_error,
28        open_target_queue_store, persist_queued_payload_to_store, redacted_secret, sanitize_queue_dir_component,
29        with_delivery_deadline,
30    },
31};
32use async_trait::async_trait;
33// Use parking_lot's Mutex for the synchronous client/TLS state guards: it does
34// not poison on panic, so a panic while a guard is held cannot cascade into
35// `.unwrap()` panics on every later access (backlog#983).
36use parking_lot::Mutex;
37use pulsar::{Authentication, Producer, Pulsar, TokioExecutor};
38use rustfs_tls_runtime::load_cert_bundle_der_bytes;
39use std::fmt;
40use std::path::Path;
41use std::sync::Arc;
42use std::sync::atomic::{AtomicBool, Ordering};
43use std::time::Duration;
44use tokio::sync::Mutex as AsyncMutex;
45use tracing::{info, instrument, warn};
46use url::Url;
47use uuid::Uuid;
48
49const PULSAR_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);
50const PULSAR_FAILED_DELIVERY_CLEANUP_TIMEOUT: Duration = Duration::from_secs(1);
51
52#[derive(Clone)]
53pub struct PulsarArgs {
54    pub enable: bool,
55    pub broker: String,
56    pub topic: String,
57    pub auth_token: String,
58    pub username: String,
59    pub password: String,
60    pub tls_ca: String,
61    pub tls_allow_insecure: bool,
62    pub tls_hostname_verification: bool,
63    pub queue_dir: String,
64    pub queue_limit: u64,
65    pub target_type: TargetType,
66}
67
68impl fmt::Debug for PulsarArgs {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.debug_struct("PulsarArgs")
71            .field("enable", &self.enable)
72            .field("broker", &self.broker)
73            .field("topic", &self.topic)
74            .field("auth_token", &redacted_secret(&self.auth_token))
75            .field("username", &self.username)
76            .field("password", &redacted_secret(&self.password))
77            .field("tls_ca", &self.tls_ca)
78            .field("tls_allow_insecure", &self.tls_allow_insecure)
79            .field("tls_hostname_verification", &self.tls_hostname_verification)
80            .field("queue_dir", &self.queue_dir)
81            .field("queue_limit", &self.queue_limit)
82            .field("target_type", &self.target_type)
83            .finish()
84    }
85}
86
87impl PulsarArgs {
88    pub fn validate(&self) -> Result<(), TargetError> {
89        if !self.enable {
90            return Ok(());
91        }
92
93        validate_pulsar_broker(&self.broker)?;
94
95        if self.topic.trim().is_empty() {
96            return Err(TargetError::Configuration("Pulsar topic cannot be empty".to_string()));
97        }
98
99        if !self.auth_token.is_empty() && (!self.username.is_empty() || !self.password.is_empty()) {
100            return Err(TargetError::Configuration(
101                "Pulsar supports either auth_token or username/password auth, not both".to_string(),
102            ));
103        }
104
105        if self.username.is_empty() != self.password.is_empty() {
106            return Err(TargetError::Configuration(
107                "Pulsar username and password must be specified together".to_string(),
108            ));
109        }
110
111        if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
112            return Err(TargetError::Configuration("Pulsar tls_ca must be an absolute path".to_string()));
113        }
114
115        if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
116            return Err(TargetError::Configuration("Pulsar queue directory must be an absolute path".to_string()));
117        }
118
119        let parsed = Url::parse(&self.broker)
120            .map_err(|e| TargetError::Configuration(format!("Invalid Pulsar broker URL: {e} (value: '{}')", self.broker)))?;
121        let tls_enabled = parsed.scheme() == "pulsar+ssl";
122        // A CA bundle is TLS trust material a plaintext `pulsar://` broker
123        // silently ignores, so treat it as a genuine misconfiguration. The
124        // `tls_allow_insecure` / `tls_hostname_verification` toggles, however,
125        // are inert on a non-TLS broker (they only take effect for
126        // `pulsar+ssl`); rejecting non-default values would leave a persisted
127        // target permanently offline after a restart even though the flags do
128        // nothing — see issue #4796.
129        if !tls_enabled && !self.tls_ca.is_empty() {
130            return Err(TargetError::Configuration(
131                "Pulsar tls_ca is only allowed with pulsar+ssl brokers".to_string(),
132            ));
133        }
134
135        Ok(())
136    }
137}
138
139pub fn validate_pulsar_broker(broker: &str) -> Result<Url, TargetError> {
140    let url = Url::parse(broker)
141        .map_err(|e| TargetError::Configuration(format!("Invalid Pulsar broker URL: {e} (value: '{broker}')")))?;
142
143    match url.scheme() {
144        "pulsar" | "pulsar+ssl" => {}
145        _ => {
146            return Err(TargetError::Configuration(
147                "Pulsar broker must use pulsar:// or pulsar+ssl://".to_string(),
148            ));
149        }
150    }
151
152    if !url.username().is_empty() || url.password().is_some() {
153        return Err(TargetError::Configuration(
154            "Pulsar broker URL must not embed username or password".to_string(),
155        ));
156    }
157
158    if url.host_str().is_none() {
159        return Err(TargetError::Configuration("Pulsar broker is missing host".to_string()));
160    }
161
162    Ok(url)
163}
164
165fn pulsar_producer_name(target_id: &TargetID, target_type: TargetType, nonce: Uuid) -> String {
166    let target_type = match target_type {
167        TargetType::NotifyEvent => "notify",
168        TargetType::AuditLog => "audit",
169    };
170
171    format!("rustfs-{target_type}-pulsar-{}-{nonce}", sanitize_queue_dir_component(&target_id.id))
172}
173
174pub async fn connect_pulsar(args: &PulsarArgs) -> Result<Pulsar<TokioExecutor>, TargetError> {
175    args.validate()?;
176
177    let mut builder = Pulsar::builder(args.broker.clone(), TokioExecutor);
178
179    if !args.auth_token.is_empty() {
180        builder = builder.with_auth(Authentication {
181            name: "token".to_string(),
182            data: args.auth_token.clone().into_bytes(),
183        });
184    } else if !args.username.is_empty() {
185        builder =
186            builder.with_auth_provider(pulsar::authentication::basic::BasicAuthentication::new(&args.username, &args.password));
187    }
188
189    if !args.tls_ca.is_empty() {
190        let certs = load_cert_bundle_der_bytes(&args.tls_ca)
191            .map_err(|e| TargetError::Configuration(format!("Failed to parse Pulsar tls_ca: {e}")))?;
192        if certs.is_empty() {
193            return Err(TargetError::Configuration(
194                "Pulsar tls_ca did not contain any parsable certificates".to_string(),
195            ));
196        }
197        builder = builder
198            .with_certificate_chain_file(&args.tls_ca)
199            .map_err(|e| TargetError::Configuration(format!("Failed to load Pulsar tls_ca: {e}")))?;
200    }
201
202    builder = builder
203        .with_allow_insecure_connection(args.tls_allow_insecure)
204        .with_tls_hostname_verification_enabled(args.tls_hostname_verification);
205
206    builder
207        .build()
208        .await
209        .map_err(|e| TargetError::Network(format!("Failed to connect to Pulsar broker: {e}")))
210}
211
212pub struct PulsarTarget<E>
213where
214    E: PluginEvent,
215{
216    id: TargetID,
217    args: PulsarArgs,
218    client: Mutex<Option<Pulsar<TokioExecutor>>>,
219    tls_state: Mutex<TargetTlsState>,
220    /// When set, the coordinator drives TLS reload; inline fingerprint check is skipped.
221    tls_adapter: Option<TlsReloadAdapter<Pulsar<TokioExecutor>>>,
222    producer: AsyncMutex<Option<Producer<TokioExecutor>>>,
223    store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
224    connected: AtomicBool,
225    delivery_counters: Arc<TargetDeliveryCounters>,
226    _phantom: std::marker::PhantomData<E>,
227}
228
229impl<E> PulsarTarget<E>
230where
231    E: PluginEvent,
232{
233    pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
234        Box::new(PulsarTarget::<E> {
235            id: self.id.clone(),
236            args: self.args.clone(),
237            client: Mutex::new(self.client.lock().clone()),
238            tls_state: Mutex::new(self.tls_state.lock().clone()),
239            tls_adapter: self.tls_adapter.clone(),
240            producer: AsyncMutex::new(None),
241            store: self.store.as_ref().map(|s| s.boxed_clone()),
242            connected: AtomicBool::new(self.connected.load(Ordering::SeqCst)),
243            delivery_counters: Arc::clone(&self.delivery_counters),
244            _phantom: std::marker::PhantomData,
245        })
246    }
247
248    #[instrument(skip(args), fields(target_id_as_string = %id))]
249    pub fn new(id: String, args: PulsarArgs) -> Result<Self, TargetError> {
250        args.validate()?;
251        let target_id = TargetID::new(id, ChannelTargetType::Pulsar.as_str().to_string());
252        let queue_store = open_target_queue_store(
253            &args.queue_dir,
254            args.queue_limit,
255            args.target_type,
256            ChannelTargetType::Pulsar.as_str(),
257            &target_id,
258            "Failed to open store for Pulsar target",
259        )?;
260
261        Ok(Self {
262            id: target_id,
263            args,
264            client: Mutex::new(None),
265            tls_state: Mutex::new(TargetTlsState::default()),
266            tls_adapter: None,
267            producer: AsyncMutex::new(None),
268            store: queue_store,
269            connected: AtomicBool::new(false),
270            delivery_counters: Arc::new(TargetDeliveryCounters::default()),
271            _phantom: std::marker::PhantomData,
272        })
273    }
274
275    fn clear_cached_client_connection(&self) {
276        self.client.lock().take();
277    }
278
279    fn clear_cached_client(&self) {
280        self.clear_cached_client_connection();
281        self.tls_state.lock().reset();
282    }
283
284    async fn clear_failed_delivery_state(&self) {
285        match tokio::time::timeout(PULSAR_FAILED_DELIVERY_CLEANUP_TIMEOUT, self.producer.lock()).await {
286            Ok(mut producer) => {
287                producer.take();
288            }
289            Err(_) => {
290                warn!(
291                    target_id = %self.id,
292                    reason = "producer_cleanup_lock_timeout",
293                    "Timed out clearing the Pulsar producer after a failed delivery"
294                );
295            }
296        }
297        self.clear_cached_client();
298        self.connected.store(false, Ordering::SeqCst);
299    }
300
301    async fn get_or_connect_client(&self) -> Result<Pulsar<TokioExecutor>, TargetError> {
302        // When a TLS reload adapter is attached, it drives client rebuilds
303        // in the background. The inline per-send fingerprint check is skipped.
304        if let Some(adapter) = &self.tls_adapter {
305            let material = adapter.current_material();
306            {
307                let mut guard = self.client.lock();
308                *guard = Some((*material).clone());
309            }
310        } else {
311            let next_fingerprint = build_target_tls_fingerprint(&self.args.tls_ca, "", "").await?;
312            let tls_changed = {
313                let tls_state_guard = self.tls_state.lock();
314                tls_state_guard.needs_update(&next_fingerprint)
315            };
316            if tls_changed {
317                self.clear_cached_client_connection();
318                self.tls_state.lock().refresh(next_fingerprint);
319            }
320        }
321
322        if let Some(client) = self.client.lock().clone() {
323            return Ok(client);
324        }
325
326        let client = connect_pulsar(&self.args).await?;
327        self.connected.store(true, Ordering::SeqCst);
328        let mut guard = self.client.lock();
329        let shared = guard.get_or_insert_with(|| client.clone()).clone();
330        Ok(shared)
331    }
332
333    async fn init_producer(&self) -> Result<(), TargetError> {
334        if self.producer.lock().await.is_some() {
335            return Ok(());
336        }
337
338        let client = self.get_or_connect_client().await?;
339        let producer = client
340            .producer()
341            .with_topic(self.args.topic.clone())
342            .with_name(pulsar_producer_name(&self.id, self.args.target_type, Uuid::new_v4()))
343            .build()
344            .await
345            .map_err(|e| TargetError::Network(format!("Failed to create Pulsar producer: {e}")))?;
346
347        let mut guard = self.producer.lock().await;
348        if guard.is_none() {
349            *guard = Some(producer);
350        }
351        Ok(())
352    }
353
354    fn build_queued_payload(&self, event: &EntityTarget<E>) -> Result<QueuedPayload, TargetError> {
355        build_queued_payload_with_records(event, vec![event.clone()])
356    }
357
358    async fn send_body(&self, body: Vec<u8>) -> Result<(), TargetError> {
359        let result = with_delivery_deadline(PULSAR_DELIVERY_TIMEOUT, "Pulsar delivery", async {
360            self.init_producer().await?;
361            let mut guard = self.producer.lock().await;
362            let producer = guard
363                .as_mut()
364                .ok_or_else(|| TargetError::Configuration("Pulsar producer not initialized".to_string()))?;
365            let receipt = producer
366                .send_non_blocking(body)
367                .await
368                .map_err(|e| TargetError::Request(format!("Failed to send Pulsar message: {e}")))?;
369            receipt
370                .await
371                .map_err(|e| TargetError::Request(format!("Failed to receive Pulsar receipt: {e}")))?;
372            self.delivery_counters.record_success();
373            Ok(())
374        })
375        .await;
376
377        if let Err(err) = &result
378            && is_connectivity_error(err)
379        {
380            self.clear_failed_delivery_state().await;
381        }
382        result
383    }
384}
385
386/// Coordinated TLS hot-reload implementation for Pulsar targets.
387///
388/// Pulsar only uses a CA certificate (no client cert/key).
389/// The coordinator calls these methods on a background poll loop to detect
390/// TLS file changes and rebuild the client without restarting.
391#[async_trait]
392impl<E> ReloadableTargetTls for PulsarTarget<E>
393where
394    E: PluginEvent,
395{
396    type Material = Pulsar<TokioExecutor>;
397
398    fn tls_input_set(&self) -> TargetTlsInputSet {
399        TargetTlsInputSet {
400            ca_path: self.args.tls_ca.clone(),
401            client_cert_path: String::new(),
402            client_key_path: String::new(),
403            target_label: format!("pulsar:{}", self.id.id),
404        }
405    }
406
407    async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
408        connect_pulsar(&self.args).await
409    }
410
411    async fn apply_tls_material(
412        &self,
413        _generation: TargetTlsGeneration,
414        material: Arc<Self::Material>,
415        _mode: ReloadApplyMode,
416    ) -> Result<(), TargetError> {
417        // Pulsar client is Clone, so we clone from the Arc and store it.
418        {
419            let mut guard = self.client.lock();
420            *guard = Some((*material).clone());
421        }
422        // Producer is bound to the old client; clear it so next send rebuilds.
423        {
424            let mut producer = self.producer.lock().await;
425            *producer = None;
426        }
427        Ok(())
428    }
429
430    async fn validate_tls_files(&self) -> Result<(), TargetError> {
431        // Pulsar only uses CA, no client cert/key.
432        validate_tls_material(&self.args.tls_ca, "", "")
433    }
434}
435
436#[async_trait]
437impl<E> Target<E> for PulsarTarget<E>
438where
439    E: PluginEvent,
440{
441    fn id(&self) -> TargetID {
442        self.id.clone()
443    }
444
445    async fn is_active(&self) -> Result<bool, TargetError> {
446        self.init_producer().await?;
447        let guard = self.producer.lock().await;
448        let producer = guard
449            .as_ref()
450            .ok_or_else(|| TargetError::Configuration("Pulsar producer not initialized".to_string()))?;
451        producer
452            .check_connection()
453            .await
454            .map_err(|e| TargetError::Network(format!("Pulsar health check failed: {e}")))?;
455        Ok(true)
456    }
457
458    async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
459        let queued = match self.build_queued_payload(&event) {
460            Ok(queued) => queued,
461            Err(err) => {
462                self.delivery_counters.record_final_failure();
463                return Err(err);
464            }
465        };
466
467        if let Some(store) = &self.store {
468            if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
469                self.delivery_counters.record_final_failure();
470                return Err(e);
471            }
472            Ok(())
473        } else {
474            if let Err(err) = self.send_body(queued.body).await {
475                self.delivery_counters.record_final_failure();
476                return Err(err);
477            }
478            Ok(())
479        }
480    }
481
482    async fn send_raw_from_store(&self, _key: Key, body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
483        self.send_body(body).await
484    }
485
486    async fn close(&self) -> Result<(), TargetError> {
487        let mut producer = self.producer.lock().await;
488        if let Some(producer) = producer.as_mut() {
489            producer
490                .close()
491                .await
492                .map_err(|e| TargetError::Network(format!("Failed to close Pulsar producer: {e}")))?;
493        }
494        *producer = None;
495        self.clear_cached_client();
496        self.connected.store(false, Ordering::SeqCst);
497        // If a TLS reload adapter is attached, reset its error tracking
498        // so that a future re-init does not inherit stale failure state.
499        if let Some(adapter) = &self.tls_adapter {
500            *adapter.runtime_state().last_error.write() = None;
501        }
502        info!(target_id = %self.id, "Pulsar target closed");
503        Ok(())
504    }
505
506    fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
507        self.store.as_deref()
508    }
509
510    fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
511        self.clone_box()
512    }
513
514    async fn init(&self) -> Result<(), TargetError> {
515        if !self.is_enabled() {
516            return Ok(());
517        }
518        self.init_producer().await
519    }
520
521    fn is_enabled(&self) -> bool {
522        self.args.enable
523    }
524
525    fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
526        self.delivery_counters.snapshot(
527            self.store.as_deref().map_or(0, |store| store.len() as u64),
528            // Pulsar targets record no terminal failures and keep no failed store.
529            0,
530        )
531    }
532
533    fn record_final_failure(&self) {
534        self.delivery_counters.record_final_failure();
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use crate::target::REDACTED_SECRET;
542
543    fn base_args() -> PulsarArgs {
544        PulsarArgs {
545            enable: true,
546            broker: "pulsar://127.0.0.1:6650".to_string(),
547            topic: "persistent://public/default/rustfs-events".to_string(),
548            auth_token: String::new(),
549            username: String::new(),
550            password: String::new(),
551            tls_ca: String::new(),
552            tls_allow_insecure: false,
553            tls_hostname_verification: true,
554            queue_dir: String::new(),
555            queue_limit: 0,
556            target_type: TargetType::NotifyEvent,
557        }
558    }
559
560    #[tokio::test(start_paused = true)]
561    async fn failed_delivery_cleanup_is_bounded_when_the_producer_lock_is_busy() {
562        let target = Arc::new(PulsarTarget::<String>::new("pulsar:test".to_string(), base_args()).expect("target should build"));
563        target.connected.store(true, Ordering::SeqCst);
564        let producer_guard = target.producer.lock().await;
565        let cleanup = {
566            let target = Arc::clone(&target);
567            tokio::spawn(async move { target.clear_failed_delivery_state().await })
568        };
569
570        cleanup.await.expect("cleanup task should not panic");
571
572        assert!(!target.connected.load(Ordering::SeqCst));
573        drop(producer_guard);
574    }
575
576    #[test]
577    fn debug_redacts_pulsar_secret_fields() {
578        let args = PulsarArgs {
579            auth_token: "pulsar-token".to_string(),
580            password: "pulsar-password".to_string(),
581            ..base_args()
582        };
583
584        let rendered = format!("{args:?}");
585
586        assert!(!rendered.contains("pulsar-token"));
587        assert!(!rendered.contains("pulsar-password"));
588        assert!(rendered.contains(REDACTED_SECRET));
589        assert!(rendered.contains("persistent://public/default/rustfs-events"));
590    }
591
592    #[test]
593    fn validate_pulsar_rejects_mixed_auth_methods() {
594        let args = PulsarArgs {
595            auth_token: "token".to_string(),
596            username: "user".to_string(),
597            password: "pass".to_string(),
598            ..base_args()
599        };
600        assert!(args.validate().is_err());
601    }
602
603    #[test]
604    fn validate_pulsar_rejects_relative_queue_dir() {
605        let args = PulsarArgs {
606            queue_dir: "relative/path".to_string(),
607            ..base_args()
608        };
609        assert!(args.validate().is_err());
610    }
611
612    #[test]
613    fn validate_pulsar_accepts_inert_tls_toggles_on_plaintext_broker() {
614        // `tls_allow_insecure` / `tls_hostname_verification` are no-ops on a
615        // `pulsar://` broker, so a target persisted with these non-default
616        // values must still validate — otherwise it stays offline after a
617        // restart (issue #4796).
618        let args = PulsarArgs {
619            tls_allow_insecure: true,
620            tls_hostname_verification: false,
621            ..base_args()
622        };
623        args.validate().expect("inert TLS toggles must not fail a plaintext broker");
624    }
625
626    #[test]
627    fn validate_pulsar_rejects_tls_ca_on_plaintext_broker() {
628        let args = PulsarArgs {
629            tls_ca: "/etc/ssl/certs/ca.pem".to_string(),
630            ..base_args()
631        };
632        assert!(args.validate().is_err());
633    }
634
635    #[test]
636    fn pulsar_producer_name_keeps_target_context_and_unique_suffix() {
637        let id = TargetID::new("test2".to_string(), ChannelTargetType::Pulsar.as_str().to_string());
638        let nonce = Uuid::from_u128(0x12345678_90ab_cdef_1234_567890abcdef);
639
640        let name = pulsar_producer_name(&id, TargetType::NotifyEvent, nonce);
641
642        assert_eq!(name, "rustfs-notify-pulsar-test2-12345678-90ab-cdef-1234-567890abcdef");
643    }
644
645    #[test]
646    fn pulsar_producer_name_includes_target_type() {
647        let id = TargetID::new("audit-target".to_string(), ChannelTargetType::Pulsar.as_str().to_string());
648        let nonce = Uuid::from_u128(0x12345678_90ab_cdef_1234_567890abcdef);
649
650        let name = pulsar_producer_name(&id, TargetType::AuditLog, nonce);
651
652        assert_eq!(name, "rustfs-audit-pulsar-audit-target-12345678-90ab-cdef-1234-567890abcdef");
653    }
654
655    #[test]
656    fn pulsar_producer_name_sanitizes_target_id() {
657        let raw_target_id = "tenant:alpha/beta";
658        let id = TargetID::new(raw_target_id.to_string(), ChannelTargetType::Pulsar.as_str().to_string());
659        let nonce = Uuid::from_u128(0x12345678_90ab_cdef_1234_567890abcdef);
660
661        let name = pulsar_producer_name(&id, TargetType::NotifyEvent, nonce);
662
663        assert!(!name.contains(':'));
664        assert!(!name.contains('/'));
665        assert_eq!(
666            name,
667            format!(
668                "rustfs-notify-pulsar-{}-12345678-90ab-cdef-1234-567890abcdef",
669                sanitize_queue_dir_component(raw_target_id)
670            )
671        );
672    }
673
674    #[test]
675    fn pulsar_producer_name_changes_for_each_producer_generation() {
676        let id = TargetID::new("test".to_string(), ChannelTargetType::Pulsar.as_str().to_string());
677        let first = Uuid::from_u128(0x12345678_90ab_cdef_1234_567890abcdef);
678        let second = Uuid::from_u128(0xfedcba09_8765_4321_fedc_ba0987654321);
679
680        assert_ne!(
681            pulsar_producer_name(&id, TargetType::NotifyEvent, first),
682            pulsar_producer_name(&id, TargetType::NotifyEvent, second)
683        );
684    }
685
686    #[test]
687    fn init_producer_uses_generated_unique_producer_name_contract() {
688        fn without_ascii_whitespace(value: &str) -> String {
689            let mut compacted = String::with_capacity(value.len());
690            compacted.extend(value.bytes().filter(|byte| !byte.is_ascii_whitespace()).map(char::from));
691            compacted
692        }
693
694        let source = without_ascii_whitespace(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/target/pulsar.rs")));
695        let expected_name_call = [
696            ".with_name(",
697            "pulsar_producer_name(&self.id, self.args.target_type, Uuid::new_v4())",
698            ")",
699        ]
700        .concat();
701        let forbidden_target_id_call = [".with_name(", "self.id.id.clone()", ")"].concat();
702
703        assert!(source.contains(&without_ascii_whitespace(&expected_name_call)));
704        assert!(!source.contains(&without_ascii_whitespace(&forbidden_target_id_call)));
705    }
706}