use crate::plugin::PluginEvent;
use crate::{
StoreError, Target,
arn::TargetID,
error::TargetError,
runtime::tls::{
ReloadableTargetTls, TargetTlsGeneration, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode,
validate_tls_material,
},
store::{Key, Store},
target::{
ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
TargetTlsState, TargetType, build_queued_payload_with_records, build_target_tls_fingerprint, is_connectivity_error,
open_target_queue_store, persist_queued_payload_to_store,
},
};
use async_trait::async_trait;
use lapin::{
BasicProperties, Channel, Confirmation, Connection, ConnectionProperties, ErrorKind as LapinErrorKind,
options::{BasicPublishOptions, ConfirmSelectOptions},
tcp::{OwnedIdentity, OwnedTLSConfig},
};
use parking_lot::Mutex;
use rustfs_config::{AMQP_TLS_CA, AMQP_TLS_CLIENT_CERT, AMQP_TLS_CLIENT_KEY};
use rustfs_tls_runtime::load_cert_bundle_der_bytes;
use std::fmt;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
use tracing::{info, instrument, warn};
use url::Url;
const AMQP_PUBLISH_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone)]
pub struct AMQPArgs {
pub enable: bool,
pub url: Url,
pub exchange: String,
pub routing_key: String,
pub mandatory: bool,
pub persistent: bool,
pub username: String,
pub password: String,
pub tls_ca: String,
pub tls_client_cert: String,
pub tls_client_key: String,
pub queue_dir: String,
pub queue_limit: u64,
pub target_type: TargetType,
}
impl fmt::Debug for AMQPArgs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AMQPArgs")
.field("enable", &self.enable)
.field("url", &redacted_amqp_url(&self.url))
.field("exchange", &self.exchange)
.field("routing_key", &self.routing_key)
.field("mandatory", &self.mandatory)
.field("persistent", &self.persistent)
.field("username", &self.username)
.field("password", if self.password.is_empty() { &"" } else { &"***REDACTED***" })
.field("tls_ca", &self.tls_ca)
.field("tls_client_cert", &self.tls_client_cert)
.field(
"tls_client_key",
if self.tls_client_key.is_empty() {
&""
} else {
&"***REDACTED***"
},
)
.field("queue_dir", &self.queue_dir)
.field("queue_limit", &self.queue_limit)
.field("target_type", &self.target_type)
.finish()
}
}
impl AMQPArgs {
pub fn validate(&self) -> Result<(), TargetError> {
if !self.enable {
return Ok(());
}
validate_amqp_url(&self.url)?;
if self.exchange.trim().is_empty() {
return Err(TargetError::Configuration("AMQP exchange cannot be empty".to_string()));
}
if self.routing_key.trim().is_empty() {
return Err(TargetError::Configuration("AMQP routing_key cannot be empty".to_string()));
}
let url_has_credentials = !self.url.username().is_empty() || self.url.password().is_some();
let config_has_credentials = !self.username.is_empty() || !self.password.is_empty();
if self.username.is_empty() != self.password.is_empty() {
return Err(TargetError::Configuration(
"AMQP username and password must be specified together".to_string(),
));
}
if url_has_credentials && config_has_credentials {
return Err(TargetError::Configuration(
"AMQP credentials must be specified either in url or username/password, not both".to_string(),
));
}
validate_amqp_tls_paths(self)?;
if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
return Err(TargetError::Configuration("AMQP queue directory must be an absolute path".to_string()));
}
Ok(())
}
}
fn redacted_amqp_url(url: &Url) -> String {
if url.password().is_none() {
return url.to_string();
}
let mut redacted = url.clone();
let _ = redacted.set_password(Some("***REDACTED***"));
redacted.to_string()
}
pub fn validate_amqp_url(url: &Url) -> Result<(), TargetError> {
match url.scheme() {
"amqp" | "amqps" => {
if url.host_str().is_none() {
return Err(TargetError::Configuration("AMQP URL is missing host".to_string()));
}
Ok(())
}
scheme => Err(TargetError::Configuration(format!(
"Unsupported AMQP URL scheme: {scheme} (only amqp and amqps are allowed)"
))),
}
}
fn validate_amqp_tls_paths(args: &AMQPArgs) -> Result<(), TargetError> {
let has_tls_settings = !args.tls_ca.is_empty() || !args.tls_client_cert.is_empty() || !args.tls_client_key.is_empty();
if has_tls_settings && args.url.scheme() != "amqps" {
return Err(TargetError::Configuration(
"AMQP TLS settings are only allowed with amqps URLs".to_string(),
));
}
if args.tls_client_cert.is_empty() != args.tls_client_key.is_empty() {
return Err(TargetError::Configuration(
"AMQP tls_client_cert and tls_client_key must be specified together".to_string(),
));
}
if !args.tls_ca.is_empty() && !Path::new(&args.tls_ca).is_absolute() {
return Err(TargetError::Configuration(format!("{AMQP_TLS_CA} must be an absolute path")));
}
if !args.tls_client_cert.is_empty() && !Path::new(&args.tls_client_cert).is_absolute() {
return Err(TargetError::Configuration(format!("{AMQP_TLS_CLIENT_CERT} must be an absolute path")));
}
if !args.tls_client_key.is_empty() && !Path::new(&args.tls_client_key).is_absolute() {
return Err(TargetError::Configuration(format!("{AMQP_TLS_CLIENT_KEY} must be an absolute path")));
}
Ok(())
}
fn connection_url(args: &AMQPArgs) -> Result<String, TargetError> {
let mut url = args.url.clone();
if !args.username.is_empty() {
url.set_username(&args.username)
.map_err(|_| TargetError::Configuration("AMQP username cannot be set on URL".to_string()))?;
url.set_password(Some(&args.password))
.map_err(|_| TargetError::Configuration("AMQP password cannot be set on URL".to_string()))?;
}
Ok(url.to_string())
}
async fn build_tls_config(args: &AMQPArgs) -> Result<OwnedTLSConfig, TargetError> {
let cert_chain = if args.tls_ca.is_empty() {
None
} else {
let certs_der = load_cert_bundle_der_bytes(&args.tls_ca)
.map_err(|e| TargetError::Configuration(format!("Failed to parse {AMQP_TLS_CA}: {e}")))?;
if certs_der.is_empty() {
return Err(TargetError::Configuration(format!(
"{AMQP_TLS_CA} did not contain any parsable certificates"
)));
}
let pem = tokio::fs::read_to_string(&args.tls_ca)
.await
.map_err(|e| TargetError::Configuration(format!("Failed to read {AMQP_TLS_CA}: {e}")))?;
Some(pem)
};
let identity = if args.tls_client_cert.is_empty() {
None
} else {
let _ = load_cert_bundle_der_bytes(&args.tls_client_cert)
.map_err(|e| TargetError::Configuration(format!("Failed to parse {AMQP_TLS_CLIENT_CERT}: {e}")))?;
let pem = tokio::fs::read(&args.tls_client_cert)
.await
.map_err(|e| TargetError::Configuration(format!("Failed to read {AMQP_TLS_CLIENT_CERT}: {e}")))?;
let key = tokio::fs::read(&args.tls_client_key)
.await
.map_err(|e| TargetError::Configuration(format!("Failed to read {AMQP_TLS_CLIENT_KEY}: {e}")))?;
Some(OwnedIdentity::PKCS8 { pem, key })
};
Ok(OwnedTLSConfig { identity, cert_chain })
}
fn build_publish_properties(args: &AMQPArgs) -> BasicProperties {
let mut properties = BasicProperties::default().with_content_type("application/json".into());
if args.persistent {
properties = properties.with_delivery_mode(2);
}
properties
}
fn is_permanent_amqp_protocol_error(err: &lapin::Error) -> bool {
use lapin::protocol::{AMQPErrorKind, AMQPHardError, AMQPSoftError};
if let LapinErrorKind::ProtocolError(amqp_err) = err.kind() {
return match amqp_err.kind() {
AMQPErrorKind::Soft(soft) => {
matches!(
soft,
AMQPSoftError::NOTFOUND | AMQPSoftError::ACCESSREFUSED | AMQPSoftError::PRECONDITIONFAILED
)
}
AMQPErrorKind::Hard(hard) => {
matches!(
hard,
AMQPHardError::NOTALLOWED | AMQPHardError::NOTIMPLEMENTED | AMQPHardError::INVALIDPATH
)
}
};
}
false
}
fn map_lapin_error(err: lapin::Error, context: &str) -> TargetError {
let message = format!("{context}: {err}");
if is_permanent_amqp_protocol_error(&err) {
return TargetError::Request(message);
}
match err.kind() {
LapinErrorKind::IOError(io_err) if io_err.kind() == std::io::ErrorKind::TimedOut => TargetError::Timeout(message),
LapinErrorKind::IOError(_)
| LapinErrorKind::InvalidConnectionState(_)
| LapinErrorKind::InvalidChannelState(..)
| LapinErrorKind::MissingHeartbeatError
| LapinErrorKind::ProtocolError(_)
if err.can_be_recovered() =>
{
TargetError::NotConnected
}
_ => TargetError::Network(message),
}
}
pub async fn connect_amqp(args: &AMQPArgs) -> Result<AMQPConnection, TargetError> {
args.validate()?;
tokio::time::timeout(Duration::from_secs(5), async {
let url = connection_url(args)?;
let properties = ConnectionProperties::default();
let connection = if args.url.scheme() == "amqps" && (!args.tls_ca.is_empty() || !args.tls_client_cert.is_empty()) {
Connection::connect_with_config(
&url,
properties,
build_tls_config(args).await?,
lapin::runtime::default_runtime()
.map_err(|e| TargetError::Initialization(format!("Failed to create AMQP runtime: {e}")))?,
)
.await
} else {
Connection::connect(&url, properties).await
}
.map_err(|e| map_lapin_error(e, "Failed to connect to AMQP broker"))?;
let channel = connection
.create_channel()
.await
.map_err(|e| map_lapin_error(e, "Failed to create AMQP channel"))?;
channel
.confirm_select(ConfirmSelectOptions::default())
.await
.map_err(|e| map_lapin_error(e, "Failed to enable AMQP publisher confirms"))?;
Ok(AMQPConnection { connection, channel })
})
.await
.unwrap_or_else(|_| Err(TargetError::Timeout("AMQP connection timed out".to_string())))
}
pub struct AMQPConnection {
pub(crate) connection: Connection,
pub(crate) channel: Channel,
}
pub struct AMQPTarget<E>
where
E: PluginEvent,
{
id: TargetID,
args: AMQPArgs,
connection: Arc<Mutex<Option<Arc<AMQPConnection>>>>,
tls_state: Arc<Mutex<TargetTlsState>>,
tls_adapter: Option<TlsReloadAdapter<AMQPConnection>>,
connect_lock: Arc<AsyncMutex<()>>,
store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
delivery_counters: Arc<TargetDeliveryCounters>,
_phantom: std::marker::PhantomData<E>,
}
impl<E> AMQPTarget<E>
where
E: PluginEvent,
{
pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(AMQPTarget::<E> {
id: self.id.clone(),
args: self.args.clone(),
connection: Arc::clone(&self.connection),
tls_state: Arc::clone(&self.tls_state),
tls_adapter: self.tls_adapter.clone(),
connect_lock: Arc::clone(&self.connect_lock),
store: self.store.as_ref().map(|s| s.boxed_clone()),
delivery_counters: Arc::clone(&self.delivery_counters),
_phantom: std::marker::PhantomData,
})
}
#[instrument(skip(args), fields(target_id_as_string = %id))]
pub fn new(id: String, args: AMQPArgs) -> Result<Self, TargetError> {
args.validate()?;
if args.enable && !args.mandatory {
warn!(
target_id = %id,
exchange = %args.exchange,
routing_key = %args.routing_key,
"AMQP target has mandatory=false: messages that route to no queue are silently dropped. Set mandatory=true for reliable delivery."
);
}
let target_id = TargetID::new(id, ChannelTargetType::Amqp.as_str().to_string());
let queue_store = open_target_queue_store(
&args.queue_dir,
args.queue_limit,
args.target_type,
ChannelTargetType::Amqp.as_str(),
&target_id,
"Failed to open store for AMQP target",
)?;
Ok(Self {
id: target_id,
args,
connection: Arc::new(Mutex::new(None)),
tls_state: Arc::new(Mutex::new(TargetTlsState::default())),
tls_adapter: None,
connect_lock: Arc::new(AsyncMutex::new(())),
store: queue_store,
delivery_counters: Arc::new(TargetDeliveryCounters::default()),
_phantom: std::marker::PhantomData,
})
}
fn build_queued_payload(&self, event: &EntityTarget<E>) -> Result<QueuedPayload, TargetError> {
build_queued_payload_with_records(event, vec![event.clone()])
}
async fn get_or_connect(&self) -> Result<Arc<AMQPConnection>, TargetError> {
if let Some(adapter) = &self.tls_adapter {
let material = adapter.current_material();
if material.connection.status().connected() && material.channel.status().connected() {
return Ok(material);
}
self.clear_connection_handle();
} else {
let next_fingerprint =
build_target_tls_fingerprint(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key).await?;
let tls_changed = {
let tls_state_guard = self.tls_state.lock();
tls_state_guard.needs_update(&next_fingerprint)
};
if tls_changed {
self.clear_connection_handle();
self.tls_state.lock().refresh(next_fingerprint);
}
}
if let Some(connection) = self.connection.lock().clone()
&& connection.connection.status().connected()
&& connection.channel.status().connected()
{
return Ok(connection);
}
let _guard = self.connect_lock.lock().await;
if let Some(connection) = self.connection.lock().clone()
&& connection.connection.status().connected()
&& connection.channel.status().connected()
{
return Ok(connection);
}
let connection = Arc::new(connect_amqp(&self.args).await?);
let mut guard = self.connection.lock();
*guard = Some(Arc::clone(&connection));
Ok(connection)
}
fn clear_connection_handle(&self) {
*self.connection.lock() = None;
}
fn clear_connection_cache(&self) {
self.clear_connection_handle();
self.tls_state.lock().reset();
}
fn clear_connection(&self) {
self.clear_connection_cache();
}
async fn send_body(&self, body: &[u8]) -> Result<(), TargetError> {
let connection = self.get_or_connect().await?;
let publish = match tokio::time::timeout(
AMQP_PUBLISH_TIMEOUT,
connection.channel.basic_publish(
self.args.exchange.clone().into(),
self.args.routing_key.clone().into(),
BasicPublishOptions {
mandatory: self.args.mandatory,
..BasicPublishOptions::default()
},
body,
build_publish_properties(&self.args),
),
)
.await
{
Ok(publish) => publish,
Err(_) => {
self.clear_connection();
return Err(TargetError::Timeout("AMQP publish timed out".to_string()));
}
};
let confirm_future = match publish {
Ok(confirm) => confirm,
Err(err) => {
self.clear_connection();
return Err(map_lapin_error(err, "Failed to publish AMQP message"));
}
};
let confirm = match tokio::time::timeout(AMQP_PUBLISH_TIMEOUT, confirm_future).await {
Ok(confirm) => confirm,
Err(_) => {
self.clear_connection();
return Err(TargetError::Timeout("AMQP publisher confirm timed out".to_string()));
}
};
match confirm {
Ok(Confirmation::Ack(None) | Confirmation::NotRequested) => {
self.delivery_counters.record_success();
Ok(())
}
Ok(Confirmation::Ack(Some(returned)) | Confirmation::Nack(Some(returned))) => {
Err(TargetError::Request(format!("AMQP broker returned message: {}", returned.reply_text)))
}
Ok(Confirmation::Nack(None)) => Err(TargetError::Request("AMQP broker negatively acknowledged message".to_string())),
Err(err) => {
self.clear_connection();
Err(map_lapin_error(err, "Failed to confirm AMQP publish"))
}
}
}
}
#[async_trait]
impl<E> ReloadableTargetTls for AMQPTarget<E>
where
E: PluginEvent,
{
type Material = AMQPConnection;
fn tls_input_set(&self) -> TargetTlsInputSet {
TargetTlsInputSet {
ca_path: self.args.tls_ca.clone(),
client_cert_path: self.args.tls_client_cert.clone(),
client_key_path: self.args.tls_client_key.clone(),
target_label: format!("amqp:{}", self.id.id),
}
}
async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
connect_amqp(&self.args).await
}
async fn apply_tls_material(
&self,
_generation: TargetTlsGeneration,
material: Arc<Self::Material>,
_mode: ReloadApplyMode,
) -> Result<(), TargetError> {
let mut guard = self.connection.lock();
*guard = Some(material);
Ok(())
}
async fn validate_tls_files(&self) -> Result<(), TargetError> {
validate_tls_material(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
}
}
#[async_trait]
impl<E> Target<E> for AMQPTarget<E>
where
E: PluginEvent,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
if !self.args.enable {
return Ok(false);
}
let connection = self.get_or_connect().await?;
Ok(connection.connection.status().connected() && connection.channel.status().connected())
}
async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
let queued = match self.build_queued_payload(&event) {
Ok(queued) => queued,
Err(err) => {
self.delivery_counters.record_final_failure();
return Err(err);
}
};
if let Some(store) = &self.store {
if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
self.delivery_counters.record_final_failure();
return Err(e);
}
Ok(())
} else {
if let Err(err) = self.send_body(&queued.body).await {
self.delivery_counters.record_final_failure();
return Err(err);
}
Ok(())
}
}
async fn send_raw_from_store(&self, _key: Key, body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
self.send_body(&body).await
}
async fn close(&self) -> Result<(), TargetError> {
let connection = self.connection.lock().take();
let mut close_result = Ok(());
if let Some(connection) = connection
&& let Err(e) = connection.connection.close(200, "OK".into()).await
{
close_result = Err(map_lapin_error(e, "Failed to close AMQP connection"));
}
self.tls_state.lock().reset();
if let Some(adapter) = &self.tls_adapter {
*adapter.runtime_state().last_error.write() = None;
}
info!(target_id = %self.id, "AMQP target closed");
close_result
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
self.store.as_deref()
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
self.clone_box()
}
async fn init(&self) -> Result<(), TargetError> {
if !self.is_enabled() {
return Ok(());
}
match self.get_or_connect().await {
Ok(_) => Ok(()),
Err(err) if self.store.is_some() && is_connectivity_error(&err) => {
warn!(target_id = %self.id, error = %err, "AMQP init failed; events will buffer in store");
Ok(())
}
Err(err) => Err(err),
}
}
fn is_enabled(&self) -> bool {
self.args.enable
}
fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
self.delivery_counters.snapshot(
self.store.as_deref().map_or(0, |store| store.len() as u64),
0,
)
}
fn record_final_failure(&self) {
self.delivery_counters.record_final_failure();
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_s3_types::EventName;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use uuid::Uuid;
fn valid_args() -> AMQPArgs {
AMQPArgs {
enable: true,
url: Url::parse("amqp://127.0.0.1:5672/%2f").unwrap(),
exchange: "rustfs.events".to_string(),
routing_key: "objects".to_string(),
mandatory: false,
persistent: true,
username: String::new(),
password: String::new(),
tls_ca: String::new(),
tls_client_cert: String::new(),
tls_client_key: String::new(),
queue_dir: String::new(),
queue_limit: 10,
target_type: TargetType::NotifyEvent,
}
}
fn unreachable_args() -> AMQPArgs {
AMQPArgs {
url: Url::parse("amqp://127.0.0.1:1/%2f").unwrap(),
..valid_args()
}
}
fn test_event() -> Arc<EntityTarget<serde_json::Value>> {
Arc::new(EntityTarget {
object_name: "object.txt".to_string(),
bucket_name: "bucket".to_string(),
event_name: EventName::ObjectCreatedPut,
data: json!({"ok": true}),
})
}
fn temp_store_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("rustfs-amqp-target-{name}-{}", Uuid::new_v4()))
}
fn assert_connect_failure(err: &TargetError) {
assert!(
matches!(err, TargetError::NotConnected | TargetError::Timeout(_)),
"unexpected error: {err}"
);
}
#[test]
fn new_rejects_invalid_args() {
let mut args = valid_args();
args.exchange.clear();
let err = match AMQPTarget::<serde_json::Value>::new("primary".to_string(), args) {
Ok(_) => panic!("invalid args should fail"),
Err(err) => err,
};
assert!(err.to_string().contains("exchange cannot be empty"));
}
#[test]
fn new_accepts_queue_mode() {
let mut args = valid_args();
args.queue_dir = temp_store_dir("queue-mode").to_string_lossy().to_string();
let target =
AMQPTarget::<serde_json::Value>::new("primary".to_string(), args.clone()).expect("queue mode should be supported");
assert!(target.store().is_some());
let _ = std::fs::remove_dir_all(args.queue_dir);
}
#[tokio::test]
async fn save_with_store_queues_event_without_broker() {
let mut args = unreachable_args();
args.queue_dir = temp_store_dir("save-store").to_string_lossy().to_string();
let target = AMQPTarget::<serde_json::Value>::new("primary".to_string(), args.clone()).expect("target should build");
target
.save(test_event())
.await
.expect("store-backed save should queue without broker");
assert_eq!(target.delivery_snapshot().queue_length, 1);
assert_eq!(target.delivery_snapshot().failed_messages, 0);
let _ = std::fs::remove_dir_all(args.queue_dir);
}
#[tokio::test]
async fn save_without_store_returns_connection_error() {
let target =
AMQPTarget::<serde_json::Value>::new("primary".to_string(), unreachable_args()).expect("target should build");
let err = target
.save(test_event())
.await
.expect_err("direct publish should fail without broker");
assert_connect_failure(&err);
assert_eq!(target.delivery_snapshot().failed_messages, 1);
}
#[tokio::test]
async fn init_with_store_allows_broker_to_recover_later() {
let mut args = unreachable_args();
args.queue_dir = temp_store_dir("init-store").to_string_lossy().to_string();
let target = AMQPTarget::<serde_json::Value>::new("primary".to_string(), args.clone()).expect("target should build");
target.init().await.expect("store-backed init should tolerate broker failure");
let _ = std::fs::remove_dir_all(args.queue_dir);
}
#[tokio::test]
async fn init_without_store_returns_connection_error() {
let target =
AMQPTarget::<serde_json::Value>::new("primary".to_string(), unreachable_args()).expect("target should build");
let err = target
.init()
.await
.expect_err("init should fail without broker when no store exists");
assert_connect_failure(&err);
}
#[tokio::test]
async fn send_raw_from_store_returns_connection_error() {
let target =
AMQPTarget::<serde_json::Value>::new("primary".to_string(), unreachable_args()).expect("target should build");
let key = Key {
name: "queued".to_string(),
extension: ".event".to_string(),
item_count: 1,
compress: false,
};
let meta = QueuedPayloadMeta::new(
EventName::ObjectCreatedPut,
"bucket".to_string(),
"object.txt".to_string(),
"application/json",
2,
);
let err = target
.send_raw_from_store(key, b"{}".to_vec(), meta)
.await
.expect_err("queue replay should fail without broker");
assert_connect_failure(&err);
}
#[test]
fn permanent_amqp_protocol_errors_are_not_connectivity_errors() {
use lapin::protocol::{AMQPError, AMQPErrorKind, AMQPHardError, AMQPSoftError};
let make = |kind: AMQPErrorKind| lapin::Error::from(LapinErrorKind::ProtocolError(AMQPError::new(kind, "boom".into())));
let not_found = make(AMQPErrorKind::Soft(AMQPSoftError::NOTFOUND));
assert!(is_permanent_amqp_protocol_error(¬_found));
assert!(matches!(map_lapin_error(not_found, "publish"), TargetError::Request(_)));
assert!(is_permanent_amqp_protocol_error(&make(AMQPErrorKind::Soft(AMQPSoftError::ACCESSREFUSED))));
assert!(is_permanent_amqp_protocol_error(&make(AMQPErrorKind::Hard(AMQPHardError::NOTALLOWED))));
assert!(!is_permanent_amqp_protocol_error(&make(AMQPErrorKind::Soft(
AMQPSoftError::RESOURCELOCKED
))));
}
#[test]
fn debug_masks_secret_values() {
let args = AMQPArgs {
url: Url::parse("amqp://guest:secret@127.0.0.1:5672/%2f").unwrap(),
password: "secret".to_string(),
tls_client_key: "/tmp/client.key".to_string(),
..valid_args()
};
let rendered = format!("{args:?}");
assert!(!rendered.contains("guest:secret"));
assert!(!rendered.contains("password: \"secret\""));
assert!(!rendered.contains("tls_client_key: \"/tmp/client.key\""));
assert!(rendered.contains("***REDACTED***"));
}
}