1use crate::{
4 dns_cache::DnsCache,
5 error::Result,
6 response_types::{
7 ProductBgdlResponse, ProductCdnsResponse, ProductVersionsResponse, SummaryResponse,
8 TypedResponse,
9 },
10 types::{Endpoint, ProtocolVersion, RIBBIT_PORT, Region},
11};
12use base64::{Engine as _, engine::general_purpose::STANDARD};
13use sha2::{Digest, Sha256};
14use std::fmt;
15use std::time::Duration;
16use tokio::io::{AsyncReadExt, AsyncWriteExt};
17use tokio::net::TcpStream;
18use tokio::time::{sleep, timeout};
19use tracing::{debug, instrument, trace, warn};
20
21const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
23
24const DEFAULT_MAX_RETRIES: u32 = 0;
26
27const DEFAULT_INITIAL_BACKOFF_MS: u64 = 100;
29
30const DEFAULT_MAX_BACKOFF_MS: u64 = 10_000;
32
33const DEFAULT_BACKOFF_MULTIPLIER: f64 = 2.0;
35
36const DEFAULT_JITTER_FACTOR: f64 = 0.1;
38
39#[derive(Clone)]
62pub struct RibbitClient {
63 region: Region,
64 protocol_version: ProtocolVersion,
65 max_retries: u32,
66 initial_backoff_ms: u64,
67 max_backoff_ms: u64,
68 backoff_multiplier: f64,
69 jitter_factor: f64,
70 dns_cache: DnsCache,
71}
72
73impl RibbitClient {
74 #[must_use]
76 pub fn new(region: Region) -> Self {
77 Self {
78 region,
79 protocol_version: ProtocolVersion::V2,
80 max_retries: DEFAULT_MAX_RETRIES,
81 initial_backoff_ms: DEFAULT_INITIAL_BACKOFF_MS,
82 max_backoff_ms: DEFAULT_MAX_BACKOFF_MS,
83 backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
84 jitter_factor: DEFAULT_JITTER_FACTOR,
85 dns_cache: DnsCache::new(),
86 }
87 }
88
89 #[must_use]
91 pub fn with_protocol_version(mut self, version: ProtocolVersion) -> Self {
92 self.protocol_version = version;
93 self
94 }
95
96 #[must_use]
101 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
102 self.max_retries = max_retries;
103 self
104 }
105
106 #[must_use]
110 pub fn with_initial_backoff_ms(mut self, initial_backoff_ms: u64) -> Self {
111 self.initial_backoff_ms = initial_backoff_ms;
112 self
113 }
114
115 #[must_use]
119 pub fn with_max_backoff_ms(mut self, max_backoff_ms: u64) -> Self {
120 self.max_backoff_ms = max_backoff_ms;
121 self
122 }
123
124 #[must_use]
128 pub fn with_backoff_multiplier(mut self, backoff_multiplier: f64) -> Self {
129 self.backoff_multiplier = backoff_multiplier;
130 self
131 }
132
133 #[must_use]
137 pub fn with_jitter_factor(mut self, jitter_factor: f64) -> Self {
138 self.jitter_factor = jitter_factor.clamp(0.0, 1.0);
139 self
140 }
141
142 #[must_use]
146 pub fn with_dns_cache_ttl(mut self, ttl: Duration) -> Self {
147 self.dns_cache = DnsCache::with_ttl(ttl);
148 self
149 }
150
151 #[must_use]
153 pub fn region(&self) -> Region {
154 self.region
155 }
156
157 pub fn set_region(&mut self, region: Region) {
159 self.region = region;
160 }
161
162 #[must_use]
164 pub fn protocol_version(&self) -> ProtocolVersion {
165 self.protocol_version
166 }
167
168 pub fn set_protocol_version(&mut self, version: ProtocolVersion) {
170 self.protocol_version = version;
171 }
172
173 #[allow(
175 clippy::cast_precision_loss,
176 clippy::cast_possible_wrap,
177 clippy::cast_possible_truncation,
178 clippy::cast_sign_loss
179 )]
180 fn calculate_backoff(&self, attempt: u32) -> Duration {
181 let base_backoff =
182 self.initial_backoff_ms as f64 * self.backoff_multiplier.powi(attempt as i32);
183 let capped_backoff = base_backoff.min(self.max_backoff_ms as f64);
184
185 let jitter_range = capped_backoff * self.jitter_factor;
187 let jitter = rand::random::<f64>() * 2.0 * jitter_range - jitter_range;
188 let final_backoff = (capped_backoff + jitter).max(0.0) as u64;
189
190 Duration::from_millis(final_backoff)
191 }
192
193 #[instrument(skip(self))]
219 pub async fn request_raw(&self, endpoint: &Endpoint) -> Result<Vec<u8>> {
220 let host = self.region.hostname();
221 let address = format!("{host}:{RIBBIT_PORT}");
222 let command = format!(
223 "{}/{}\n",
224 self.protocol_version.prefix(),
225 endpoint.as_path()
226 );
227
228 let mut last_error = None;
229
230 for attempt in 0..=self.max_retries {
231 if attempt > 0 {
232 let backoff = self.calculate_backoff(attempt - 1);
233 debug!("Retry attempt {} after {:?} backoff", attempt, backoff);
234 sleep(backoff).await;
235 }
236
237 debug!(
238 "Connecting to Ribbit service at {address} (attempt {})",
239 attempt + 1
240 );
241
242 match self.attempt_request(&address, &command).await {
244 Ok(response) => {
245 let len = response.len();
246 debug!("Received {len} bytes");
247 return Ok(response);
248 }
249 Err(e) => {
250 let is_retryable = matches!(
252 &e,
253 crate::error::Error::ConnectionFailed { .. }
254 | crate::error::Error::ConnectionTimeout { .. }
255 | crate::error::Error::SendFailed
256 | crate::error::Error::ReceiveFailed
257 );
258
259 if is_retryable && attempt < self.max_retries {
260 warn!(
261 "Request failed (attempt {}): {}, will retry",
262 attempt + 1,
263 e
264 );
265 last_error = Some(e);
266 } else {
267 debug!(
269 "Request failed (attempt {}): {}, not retrying",
270 attempt + 1,
271 e
272 );
273 return Err(e);
274 }
275 }
276 }
277 }
278
279 Err(
281 last_error.unwrap_or_else(|| crate::error::Error::ConnectionFailed {
282 host: host.to_string(),
283 port: RIBBIT_PORT,
284 }),
285 )
286 }
287
288 async fn attempt_request(&self, _address: &str, command: &str) -> Result<Vec<u8>> {
290 let host = self.region.hostname();
291
292 let socket_addrs = self
294 .dns_cache
295 .resolve(host, RIBBIT_PORT)
296 .await
297 .map_err(|_| crate::error::Error::ConnectionFailed {
298 host: host.to_string(),
299 port: RIBBIT_PORT,
300 })?;
301
302 let timeout_duration = Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS);
304 let mut last_error = None;
305
306 for socket_addr in &socket_addrs {
307 debug!("Trying to connect to {:?}", socket_addr);
308 let connect_future = TcpStream::connect(socket_addr);
309
310 match timeout(timeout_duration, connect_future).await {
311 Ok(Ok(mut stream)) => {
312 let trimmed = command.trim();
314 trace!("Sending command: {trimmed}");
315
316 stream
318 .write_all(command.as_bytes())
319 .await
320 .map_err(|_| crate::error::Error::SendFailed)?;
321
322 let mut response = Vec::new();
324 stream
325 .read_to_end(&mut response)
326 .await
327 .map_err(|_| crate::error::Error::ReceiveFailed)?;
328
329 return Ok(response);
330 }
331 Ok(Err(e)) => {
332 debug!("Connection failed to {:?}: {}", socket_addr, e);
333 last_error = Some(crate::error::Error::ConnectionFailed {
334 host: host.to_string(),
335 port: RIBBIT_PORT,
336 });
337 }
339 Err(_) => {
340 debug!(
341 "Connection timed out after {} seconds to {:?}",
342 DEFAULT_CONNECT_TIMEOUT_SECS, socket_addr
343 );
344 last_error = Some(crate::error::Error::ConnectionTimeout {
345 host: host.to_string(),
346 port: RIBBIT_PORT,
347 timeout_secs: DEFAULT_CONNECT_TIMEOUT_SECS,
348 });
349 }
351 }
352 }
353
354 Err(
356 last_error.unwrap_or_else(|| crate::error::Error::ConnectionFailed {
357 host: host.to_string(),
358 port: RIBBIT_PORT,
359 }),
360 )
361 }
362
363 #[instrument(skip(self))]
373 pub async fn request(&self, endpoint: &Endpoint) -> Result<Response> {
374 let raw_response = self.request_raw(endpoint).await?;
375
376 match self.protocol_version {
377 ProtocolVersion::V1 => Response::parse_v1(&raw_response),
378 ProtocolVersion::V2 => Ok(Response::parse_v2(&raw_response)),
379 }
380 }
381
382 #[instrument(skip(self))]
411 pub async fn request_typed<T: TypedResponse>(&self, endpoint: &Endpoint) -> Result<T> {
412 let response = self.request(endpoint).await?;
413 T::from_response(&response)
414 }
415
416 pub async fn get_product_versions(&self, product: &str) -> Result<ProductVersionsResponse> {
442 self.request_typed(&Endpoint::ProductVersions(product.to_string()))
443 .await
444 }
445
446 pub async fn get_product_cdns(&self, product: &str) -> Result<ProductCdnsResponse> {
457 self.request_typed(&Endpoint::ProductCdns(product.to_string()))
458 .await
459 }
460
461 pub async fn get_product_bgdl(&self, product: &str) -> Result<ProductBgdlResponse> {
472 self.request_typed(&Endpoint::ProductBgdl(product.to_string()))
473 .await
474 }
475
476 pub async fn get_summary(&self) -> Result<SummaryResponse> {
502 self.request_typed(&Endpoint::Summary).await
503 }
504}
505
506#[derive(Debug)]
510pub struct Response {
511 pub raw: Vec<u8>,
513 pub data: Option<String>,
515 pub mime_parts: Option<MimeParts>,
517}
518
519#[derive(Debug)]
521pub struct MimeParts {
522 pub data: String,
524 pub signature: Option<Vec<u8>>,
526 pub signature_info: Option<crate::signature::SignatureInfo>,
528 pub signature_verification: Option<crate::signature_verify::EnhancedSignatureInfo>,
530 pub checksum: Option<String>,
532}
533
534impl Response {
535 #[must_use]
539 pub fn as_text(&self) -> Option<&str> {
540 self.data.as_deref()
541 }
542
543 pub fn as_bpsv(&self) -> Result<ngdp_bpsv::BpsvDocument> {
551 match &self.data {
552 Some(data) => {
553 ngdp_bpsv::BpsvDocument::parse(data)
555 .map_err(|e| crate::error::Error::ParseError(format!("BPSV parse error: {e}")))
556 }
557 None => Err(crate::error::Error::ParseError(
558 "No data in response".to_string(),
559 )),
560 }
561 }
562
563 #[allow(clippy::too_many_lines)]
565 fn parse_v1(raw: &[u8]) -> Result<Self> {
566 let (_, checksum) = Self::extract_checksum(raw);
568 debug!("Extracted checksum from V1 response: {checksum:?}");
569
570 let message = mail_parser::MessageParser::default().parse(raw).ok_or(
572 crate::error::Error::MimeParseError("Failed to parse MIME message".to_string()),
573 )?;
574
575 if let Some(expected_checksum) = &checksum {
577 let (message_bytes_for_validation, _) = Self::extract_checksum(raw);
579 Self::validate_checksum(message_bytes_for_validation, expected_checksum)?;
580 }
581
582 let parts_count = message.parts.len();
583 let text_body = &message.text_body;
584 trace!(
585 "Parsed message - parts count: {parts_count}, text_body indices: {text_body:?}, checksum: {checksum:?}"
586 );
587
588 let mut data_content = None;
590 let mut signature_content = None;
591
592 for (idx, part) in message.parts.iter().enumerate() {
594 let headers_count = part.headers.len();
595 trace!("Processing part {idx}: headers count = {headers_count}");
596
597 for header in &part.headers {
599 let value_str = match &header.value {
600 mail_parser::HeaderValue::Text(t) => format!("Text: {t}"),
601 mail_parser::HeaderValue::TextList(list) => format!("TextList: {list:?}"),
602 mail_parser::HeaderValue::ContentType(ct) => format!("ContentType: {ct:?}"),
603 _ => format!("Other: {:?}", header.value),
604 };
605 let name = &header.name;
606 trace!(" Header: {name} = {value_str}");
607 }
608
609 let disposition = part
611 .headers
612 .iter()
613 .find(|h| {
614 let name = h.name.as_str();
615 name == "Content-Disposition" || name.to_lowercase() == "content-disposition"
616 })
617 .map(|h| match &h.value {
618 mail_parser::HeaderValue::ContentType(ct) => ct.c_type.as_ref(),
619 mail_parser::HeaderValue::Text(t) => t.as_ref(),
620 _ => "",
621 })
622 .unwrap_or_default();
623
624 trace!("Part {idx} disposition: '{disposition}'");
625
626 if disposition.contains("version")
628 || disposition.contains("cdns")
629 || disposition.contains("bgdl")
630 || disposition.contains("cert")
631 || disposition.contains("ocsp")
632 || disposition.contains("summary")
633 {
634 if let mail_parser::PartType::Text(text) = &part.body {
635 data_content = Some(text.as_ref().to_string());
636 }
637 } else if disposition.contains("signature") {
638 match &part.body {
640 mail_parser::PartType::Binary(binary) => {
641 signature_content = Some(binary.as_ref().to_vec());
642 }
643 mail_parser::PartType::Text(text) => {
644 let text_str = text.as_ref().trim();
646 match STANDARD.decode(text_str) {
648 Ok(decoded) => signature_content = Some(decoded),
649 Err(_) => {
650 signature_content = Some(text.as_bytes().to_vec());
652 }
653 }
654 }
655 _ => {}
656 }
657 }
658 }
659
660 if data_content.is_none() {
662 if !message.text_body.is_empty() {
664 if let Some(text) = message.body_text(0) {
666 data_content = Some(text.to_string());
667 }
668 }
669
670 if data_content.is_none() {
672 let raw_msg = message.raw_message.as_ref();
673 if let Some(body_start) = raw_msg.windows(4).position(|w| w == b"\r\n\r\n") {
675 let body_bytes = &raw_msg[body_start + 4..];
676 let body_text = String::from_utf8_lossy(body_bytes);
677 data_content = Some(body_text.trim_end().to_string());
679 }
680 }
681 }
682
683 let mime_parts =
684 if data_content.is_some() || signature_content.is_some() || checksum.is_some() {
685 let (signature_info, signature_verification) =
687 if let Some(ref sig_bytes) = signature_content {
688 let data_for_verification = if checksum.is_some() {
690 let (data_without_checksum, _) = Self::extract_checksum(raw);
691 data_without_checksum
692 } else {
693 raw
694 };
695
696 match crate::signature_verify::parse_and_verify_signature(
698 sig_bytes,
699 Some(data_for_verification),
700 ) {
701 Ok(enhanced_info) => {
702 debug!("Enhanced signature parsing: {enhanced_info:?}");
703 let basic_info = crate::signature::SignatureInfo {
705 format: enhanced_info.format.clone(),
706 size: enhanced_info.size,
707 algorithm: enhanced_info.digest_algorithm.clone(),
708 signer_count: enhanced_info.signer_count,
709 certificate_count: enhanced_info.certificates.len(),
710 };
711 (Some(basic_info), Some(enhanced_info))
712 }
713 Err(e) => {
714 warn!("Enhanced signature parsing failed: {e}");
715 match crate::signature::parse_signature(sig_bytes) {
717 Ok(info) => {
718 debug!("Basic signature parsing: {info:?}");
719 (Some(info), None)
720 }
721 Err(e) => {
722 warn!("Failed to parse signature: {e}");
723 (None, None)
724 }
725 }
726 }
727 }
728 } else {
729 (None, None)
730 };
731
732 Some(MimeParts {
733 data: data_content.clone().unwrap_or_default(),
734 signature: signature_content,
735 signature_info,
736 signature_verification,
737 checksum,
738 })
739 } else {
740 None
741 };
742
743 Ok(Response {
744 raw: raw.to_vec(),
745 data: data_content,
746 mime_parts,
747 })
748 }
749
750 fn parse_v2(raw: &[u8]) -> Self {
752 let data = String::from_utf8_lossy(raw).to_string();
753 Response {
754 raw: raw.to_vec(),
755 data: Some(data),
756 mime_parts: None,
757 }
758 }
759
760 fn extract_checksum(raw: &[u8]) -> (&[u8], Option<String>) {
762 const CHECKSUM_PREFIX: &[u8] = b"Checksum: ";
763
764 if let Some(checksum_pos) = raw
766 .windows(CHECKSUM_PREFIX.len())
767 .rposition(|window| window == CHECKSUM_PREFIX)
768 {
769 trace!("Found checksum at position {checksum_pos}");
770 let checksum_line_start = checksum_pos;
772
773 let checksum_line_end = raw[checksum_line_start..]
775 .iter()
776 .position(|&b| b == b'\n')
777 .map_or(raw.len(), |pos| checksum_line_start + pos + 1);
778
779 let hex_start = checksum_pos + CHECKSUM_PREFIX.len();
781 let mut hex_end = if checksum_line_end > 0 && raw[checksum_line_end - 1] == b'\n' {
782 checksum_line_end - 1
783 } else {
784 checksum_line_end
785 };
786
787 if hex_end > 0 && raw[hex_end - 1] == b'\r' {
789 hex_end -= 1;
790 }
791
792 if hex_start < hex_end {
793 let checksum = String::from_utf8_lossy(&raw[hex_start..hex_end]).to_string();
794 if checksum.len() == 64 && checksum.chars().all(|c| c.is_ascii_hexdigit()) {
796 trace!("Valid checksum found: {checksum}");
797 let message_bytes = &raw[..checksum_line_start];
799 return (message_bytes, Some(checksum));
800 }
801 let len = checksum.len();
802 trace!("Invalid checksum format - length: {len}, content: {checksum:?}");
803 }
804 }
805
806 let len = raw.len();
808 trace!("No checksum found in {len} bytes of data");
809 (raw, None)
810 }
811
812 fn validate_checksum(message_bytes: &[u8], expected_checksum: &str) -> Result<()> {
814 let mut hasher = Sha256::new();
815 hasher.update(message_bytes);
816 let computed = hasher.finalize();
817 let computed_hex = format!("{computed:x}");
818
819 if computed_hex != expected_checksum {
820 warn!("Checksum mismatch: expected {expected_checksum}, computed {computed_hex}");
821 return Err(crate::error::Error::ChecksumMismatch);
822 }
823
824 debug!("Checksum validation successful");
825 Ok(())
826 }
827}
828
829impl Default for RibbitClient {
830 fn default() -> Self {
831 Self::new(Region::US)
832 }
833}
834
835impl fmt::Display for Response {
836 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
837 match &self.data {
838 Some(data) => write!(f, "{data}"),
839 None => write!(f, "<empty response>"),
840 }
841 }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847
848 #[test]
849 fn test_client_creation() {
850 let client = RibbitClient::new(Region::EU);
851 assert_eq!(client.region(), Region::EU);
852 assert_eq!(client.protocol_version(), ProtocolVersion::V2);
853 }
854
855 #[test]
856 fn test_client_with_protocol_version() {
857 let client = RibbitClient::new(Region::US).with_protocol_version(ProtocolVersion::V2);
858 assert_eq!(client.region(), Region::US);
859 assert_eq!(client.protocol_version(), ProtocolVersion::V2);
860 }
861
862 #[test]
863 fn test_client_setters() {
864 let mut client = RibbitClient::new(Region::US);
865
866 client.set_region(Region::KR);
867 assert_eq!(client.region(), Region::KR);
868
869 client.set_protocol_version(ProtocolVersion::V2);
870 assert_eq!(client.protocol_version(), ProtocolVersion::V2);
871 }
872
873 #[test]
874 fn test_client_default() {
875 let client = RibbitClient::default();
876 assert_eq!(client.region(), Region::US);
877 assert_eq!(client.protocol_version(), ProtocolVersion::V2);
878 }
879
880 #[tokio::test]
881 async fn test_connection_timeout() {
882 let client = RibbitClient::new(Region::CN);
884 let result = client.request_raw(&Endpoint::Summary).await;
885
886 if result.is_err() {
890 let err = result.unwrap_err();
891 match err {
893 crate::error::Error::ConnectionTimeout { .. }
894 | crate::error::Error::ConnectionFailed { .. } => {
895 }
898 _ => panic!("Unexpected error type: {err:?}"),
899 }
900 }
901 }
902
903 #[test]
904 fn test_response_parse_v2() {
905 let raw_data = b"test data\nwith lines";
906 let response = Response::parse_v2(raw_data);
907
908 assert_eq!(response.raw, raw_data);
909 assert_eq!(response.data.unwrap(), "test data\nwith lines");
910 assert!(response.mime_parts.is_none());
911 }
912
913 #[test]
914 fn test_extract_checksum() {
915 let data_with_checksum = b"Some MIME data here\nChecksum: 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\n";
917 let (message, checksum) = Response::extract_checksum(data_with_checksum);
918
919 assert_eq!(message, b"Some MIME data here\n");
920 assert_eq!(
921 checksum,
922 Some("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string())
923 );
924
925 let data_no_checksum = b"Just some data";
927 let (message, checksum) = Response::extract_checksum(data_no_checksum);
928
929 assert_eq!(message, data_no_checksum);
930 assert!(checksum.is_none());
931 }
932
933 #[test]
934 fn test_validate_checksum() {
935 use sha2::{Digest, Sha256};
936
937 let message = b"test message";
939
940 let mut hasher = Sha256::new();
942 hasher.update(message);
943 let expected = format!("{:x}", hasher.finalize());
944
945 assert!(Response::validate_checksum(message, &expected).is_ok());
947
948 let wrong_checksum = "0000000000000000000000000000000000000000000000000000000000000000";
950 assert!(Response::validate_checksum(message, wrong_checksum).is_err());
951 }
952
953 #[test]
954 fn test_parse_v1_simple_mime() {
955 let mime_data = concat!(
957 "Content-Type: text/plain\r\n",
958 "From: Test\r\n",
959 "\r\n",
960 "Region!STRING:0|BuildConfig!HEX:16\r\n",
961 "us|abcdef1234567890\r\n"
962 )
963 .as_bytes();
964
965 let response = Response::parse_v1(mime_data).unwrap();
966
967 assert!(response.data.is_some());
968 assert!(response.data.unwrap().contains("Region!STRING:0"));
969 assert!(response.mime_parts.is_some());
970 }
971
972 #[test]
973 fn test_parse_v1_with_checksum() {
974 use sha2::{Digest, Sha256};
975
976 let mime_data =
978 concat!("Content-Type: text/plain\r\n", "\r\n", "test data\r\n",).as_bytes();
979
980 let mut data_with_checksum = mime_data.to_vec();
982
983 let mut hasher = Sha256::new();
985 hasher.update(&data_with_checksum);
986 let checksum = format!("Checksum: {:x}\n", hasher.finalize());
987 data_with_checksum.extend_from_slice(checksum.as_bytes());
988
989 let response = Response::parse_v1(&data_with_checksum).unwrap();
990 assert!(response.mime_parts.is_some());
991 assert!(response.mime_parts.unwrap().checksum.is_some());
992 }
993
994 #[test]
995 fn test_parse_v1_multipart_with_checksum() {
996 let mime_data = concat!(
998 "MIME-Version: 1.0\r\n",
999 "Content-Type: multipart/mixed; boundary=\"test-boundary\"\r\n",
1000 "\r\n",
1001 "--test-boundary\r\n",
1002 "Content-Type: text/plain\r\n",
1003 "\r\n",
1004 "Product data here\r\n",
1005 "--test-boundary--\r\n",
1006 "Checksum: 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\r\n"
1007 )
1008 .as_bytes();
1009
1010 let response = Response::parse_v1(mime_data);
1011
1012 if let Err(crate::error::Error::ChecksumMismatch) = response {
1014 } else {
1017 let response = response.unwrap();
1018 assert!(response.mime_parts.is_some());
1019 assert!(response.mime_parts.unwrap().checksum.is_some());
1020 }
1021 }
1022
1023 #[test]
1024 fn test_parse_v1_with_signature() {
1025 let mut mime_data = Vec::new();
1027 mime_data.extend_from_slice(b"MIME-Version: 1.0\r\n");
1028 mime_data
1029 .extend_from_slice(b"Content-Type: multipart/mixed; boundary=\"test-boundary\"\r\n");
1030 mime_data.extend_from_slice(b"\r\n");
1031 mime_data.extend_from_slice(b"--test-boundary\r\n");
1032 mime_data.extend_from_slice(b"Content-Type: text/plain\r\n");
1033 mime_data.extend_from_slice(b"Content-Disposition: version\r\n");
1034 mime_data.extend_from_slice(b"\r\n");
1035 mime_data.extend_from_slice(b"Product data here\r\n");
1036 mime_data.extend_from_slice(b"--test-boundary\r\n");
1037 mime_data.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
1038 mime_data.extend_from_slice(b"Content-Disposition: signature\r\n");
1039 mime_data.extend_from_slice(b"\r\n");
1040 mime_data.extend_from_slice(&[
1042 0x30, 0x82, 0x01, 0xde, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07,
1043 0x02, 0xa0, 0x82, 0x01, 0xcf, 0x00,
1044 ]);
1045 mime_data.extend_from_slice(b"\r\n");
1046 mime_data.extend_from_slice(b"--test-boundary--\r\n");
1047
1048 let response = Response::parse_v1(&mime_data).unwrap();
1049 assert!(response.mime_parts.is_some());
1050
1051 let mime_parts = response.mime_parts.unwrap();
1052 assert!(mime_parts.signature.is_some());
1053
1054 let sig_len = mime_parts.signature.as_ref().unwrap().len();
1056 assert!(
1057 sig_len > 0,
1058 "Signature should not be empty, got {sig_len} bytes"
1059 );
1060
1061 if let Some(sig_info) = mime_parts.signature_info {
1064 assert_eq!(sig_info.format, "PKCS#7/CMS");
1065 }
1066 }
1067
1068 #[test]
1069 fn test_client_retry_configuration() {
1070 let client = RibbitClient::new(Region::US)
1071 .with_max_retries(3)
1072 .with_initial_backoff_ms(200)
1073 .with_max_backoff_ms(5000)
1074 .with_backoff_multiplier(1.5)
1075 .with_jitter_factor(0.2);
1076
1077 assert_eq!(client.max_retries, 3);
1078 assert_eq!(client.initial_backoff_ms, 200);
1079 assert_eq!(client.max_backoff_ms, 5000);
1080 assert!((client.backoff_multiplier - 1.5).abs() < f64::EPSILON);
1081 assert!((client.jitter_factor - 0.2).abs() < f64::EPSILON);
1082 }
1083
1084 #[test]
1085 fn test_jitter_factor_clamping() {
1086 let client1 = RibbitClient::new(Region::US).with_jitter_factor(1.5);
1087 assert!((client1.jitter_factor - 1.0).abs() < f64::EPSILON); let client2 = RibbitClient::new(Region::US).with_jitter_factor(-0.5);
1090 assert!((client2.jitter_factor - 0.0).abs() < f64::EPSILON); }
1092
1093 #[test]
1094 fn test_backoff_calculation() {
1095 let client = RibbitClient::new(Region::US)
1096 .with_initial_backoff_ms(100)
1097 .with_max_backoff_ms(1000)
1098 .with_backoff_multiplier(2.0)
1099 .with_jitter_factor(0.0); let backoff0 = client.calculate_backoff(0);
1103 assert_eq!(backoff0.as_millis(), 100); let backoff1 = client.calculate_backoff(1);
1106 assert_eq!(backoff1.as_millis(), 200); let backoff2 = client.calculate_backoff(2);
1109 assert_eq!(backoff2.as_millis(), 400); let backoff5 = client.calculate_backoff(5);
1113 assert_eq!(backoff5.as_millis(), 1000); }
1115
1116 #[test]
1117 fn test_default_retry_configuration() {
1118 let client = RibbitClient::new(Region::US);
1119 assert_eq!(client.max_retries, 0); }
1121}