ribbit_client/
client.rs

1//! Ribbit TCP client implementation
2
3use 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
21/// Default connection timeout in seconds
22const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
23
24/// Default maximum retries (0 = no retries, maintains backward compatibility)
25const DEFAULT_MAX_RETRIES: u32 = 0;
26
27/// Default initial backoff in milliseconds
28const DEFAULT_INITIAL_BACKOFF_MS: u64 = 100;
29
30/// Default maximum backoff in milliseconds
31const DEFAULT_MAX_BACKOFF_MS: u64 = 10_000;
32
33/// Default backoff multiplier
34const DEFAULT_BACKOFF_MULTIPLIER: f64 = 2.0;
35
36/// Default jitter factor (0.0 to 1.0)
37const DEFAULT_JITTER_FACTOR: f64 = 0.1;
38
39/// Ribbit TCP client for querying Blizzard version services
40///
41/// The client supports multiple regions and both V1 (MIME) and V2 (raw PSV) protocols.
42/// It also supports automatic retries with exponential backoff for transient network errors.
43///
44/// # Example
45///
46/// ```no_run
47/// use ribbit_client::{RibbitClient, Region, ProtocolVersion, Endpoint};
48///
49/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
50/// // Create a client for EU region with V2 protocol
51/// let client = RibbitClient::new(Region::EU)
52///     .with_protocol_version(ProtocolVersion::V2)
53///     .with_max_retries(3);
54///
55/// // Request version information
56/// let endpoint = Endpoint::ProductVersions("wow".to_string());
57/// let response = client.request(&endpoint).await?;
58/// # Ok(())
59/// # }
60/// ```
61#[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    /// Create a new Ribbit client with the specified region
75    #[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    /// Set the protocol version to use
90    #[must_use]
91    pub fn with_protocol_version(mut self, version: ProtocolVersion) -> Self {
92        self.protocol_version = version;
93        self
94    }
95
96    /// Set the maximum number of retries for failed requests
97    ///
98    /// Default is 0 (no retries) to maintain backward compatibility.
99    /// Only network and connection errors are retried, not parsing errors.
100    #[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    /// Set the initial backoff duration in milliseconds
107    ///
108    /// Default is 100ms. This is the base delay before the first retry.
109    #[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    /// Set the maximum backoff duration in milliseconds
116    ///
117    /// Default is 10,000ms (10 seconds). Backoff will not exceed this value.
118    #[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    /// Set the backoff multiplier
125    ///
126    /// Default is 2.0. The backoff duration is multiplied by this value after each retry.
127    #[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    /// Set the jitter factor (0.0 to 1.0)
134    ///
135    /// Default is 0.1 (10% jitter). Adds randomness to prevent thundering herd.
136    #[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    /// Set the DNS cache TTL
143    ///
144    /// Default is 300 seconds (5 minutes).
145    #[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    /// Get the current region
152    #[must_use]
153    pub fn region(&self) -> Region {
154        self.region
155    }
156
157    /// Set the region
158    pub fn set_region(&mut self, region: Region) {
159        self.region = region;
160    }
161
162    /// Get the current protocol version
163    #[must_use]
164    pub fn protocol_version(&self) -> ProtocolVersion {
165        self.protocol_version
166    }
167
168    /// Set the protocol version
169    pub fn set_protocol_version(&mut self, version: ProtocolVersion) {
170        self.protocol_version = version;
171    }
172
173    /// Calculate backoff duration with exponential backoff and jitter
174    #[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        // Add jitter
186        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    /// Send a request to the Ribbit service and get the raw response
194    ///
195    /// This method supports automatic retries with exponential backoff for
196    /// transient network errors. Parsing errors are not retried.
197    ///
198    /// # Example
199    ///
200    /// ```no_run
201    /// # use ribbit_client::{RibbitClient, Region, Endpoint};
202    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
203    /// let client = RibbitClient::new(Region::US)
204    ///     .with_max_retries(3);
205    /// let raw_data = client.request_raw(&Endpoint::Summary).await?;
206    /// println!("Received {} bytes", raw_data.len());
207    /// # Ok(())
208    /// # }
209    /// ```
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if:
214    /// - The connection to the Ribbit server fails after all retries
215    /// - Sending the request fails
216    /// - Receiving the response fails
217    /// - The response is invalid or incomplete
218    #[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            // Try to connect and send request
243            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                    // Check if error is retryable
251                    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                        // Non-retryable error or final attempt
268                        debug!(
269                            "Request failed (attempt {}): {}, not retrying",
270                            attempt + 1,
271                            e
272                        );
273                        return Err(e);
274                    }
275                }
276            }
277        }
278
279        // This should only be reached if all retries failed
280        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    /// Attempt a single request (helper for retry logic)
289    async fn attempt_request(&self, _address: &str, command: &str) -> Result<Vec<u8>> {
290        let host = self.region.hostname();
291
292        // Resolve hostname using DNS cache
293        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        // Try connecting to resolved addresses
303        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                    // Successfully connected
313                    let trimmed = command.trim();
314                    trace!("Sending command: {trimmed}");
315
316                    // Send the command
317                    stream
318                        .write_all(command.as_bytes())
319                        .await
320                        .map_err(|_| crate::error::Error::SendFailed)?;
321
322                    // Read the response until EOF (server closes connection)
323                    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                    // Try next address
338                }
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                    // Try next address
350                }
351            }
352        }
353
354        // All addresses failed, return the last error
355        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    /// Send a request to the Ribbit service and parse the response
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if:
368    /// - The raw request fails (see [`request_raw`](Self::request_raw))
369    /// - Parsing the response fails
370    /// - V1 responses fail checksum validation
371    /// - V1 responses have invalid MIME structure
372    #[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    /// Request with automatic type parsing
383    ///
384    /// This method automatically parses the response into the appropriate typed structure
385    /// based on the type parameter.
386    ///
387    /// # Example
388    ///
389    /// ```no_run
390    /// # use ribbit_client::{RibbitClient, Region, Endpoint, ProductVersionsResponse};
391    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
392    /// let client = RibbitClient::new(Region::US);
393    /// let versions: ProductVersionsResponse = client
394    ///     .request_typed(&Endpoint::ProductVersions("wow".to_string()))
395    ///     .await?;
396    ///
397    /// for entry in &versions.entries {
398    ///     println!("{}: {} (build {})", entry.region, entry.versions_name, entry.build_id);
399    /// }
400    /// # Ok(())
401    /// # }
402    /// ```
403    ///
404    /// # Errors
405    ///
406    /// Returns an error if:
407    /// - The request fails
408    /// - The response cannot be parsed as BPSV
409    /// - The BPSV data doesn't match the expected schema
410    #[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    /// Request product versions with typed response
417    ///
418    /// Convenience method for requesting product version information.
419    ///
420    /// # Example
421    ///
422    /// ```no_run
423    /// # use ribbit_client::{RibbitClient, Region};
424    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
425    /// let client = RibbitClient::new(Region::US);
426    /// let versions = client.get_product_versions("wow").await?;
427    ///
428    /// if let Some(us_version) = versions.get_region("us") {
429    ///     println!("US version: {}", us_version.versions_name);
430    /// }
431    /// # Ok(())
432    /// # }
433    /// ```
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if:
438    /// - The request fails
439    /// - The response cannot be parsed as BPSV
440    /// - The BPSV data doesn't match the expected schema
441    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    /// Request product CDNs with typed response
447    ///
448    /// Convenience method for requesting CDN server information.
449    ///
450    /// # Errors
451    ///
452    /// Returns an error if:
453    /// - The request fails
454    /// - The response cannot be parsed as BPSV
455    /// - The BPSV data doesn't match the expected schema
456    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    /// Request product background download config with typed response
462    ///
463    /// Convenience method for requesting background download configuration.
464    ///
465    /// # Errors
466    ///
467    /// Returns an error if:
468    /// - The request fails
469    /// - The response cannot be parsed as BPSV
470    /// - The BPSV data doesn't match the expected schema
471    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    /// Request summary of all products with typed response
477    ///
478    /// Convenience method for requesting the summary of all available products.
479    ///
480    /// # Example
481    ///
482    /// ```no_run
483    /// # use ribbit_client::{RibbitClient, Region};
484    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
485    /// let client = RibbitClient::new(Region::US);
486    /// let summary = client.get_summary().await?;
487    ///
488    /// for product in &summary.products {
489    ///     println!("{}: seqn {}", product.product, product.seqn);
490    /// }
491    /// # Ok(())
492    /// # }
493    /// ```
494    ///
495    /// # Errors
496    ///
497    /// Returns an error if:
498    /// - The request fails
499    /// - The response cannot be parsed as BPSV
500    /// - The BPSV data doesn't match the expected schema
501    pub async fn get_summary(&self) -> Result<SummaryResponse> {
502        self.request_typed(&Endpoint::Summary).await
503    }
504}
505
506/// Parsed Ribbit response
507///
508/// Contains the raw response data and parsed components based on the protocol version.
509#[derive(Debug)]
510pub struct Response {
511    /// Raw response data
512    pub raw: Vec<u8>,
513    /// Parsed data (PSV format)
514    pub data: Option<String>,
515    /// MIME parts (V1 only)
516    pub mime_parts: Option<MimeParts>,
517}
518
519/// MIME parts from a V1 response
520#[derive(Debug)]
521pub struct MimeParts {
522    /// Main data content
523    pub data: String,
524    /// Signature data (if present)
525    pub signature: Option<Vec<u8>>,
526    /// Parsed signature information
527    pub signature_info: Option<crate::signature::SignatureInfo>,
528    /// Enhanced signature verification info (if available)
529    pub signature_verification: Option<crate::signature_verify::EnhancedSignatureInfo>,
530    /// Checksum from epilogue
531    pub checksum: Option<String>,
532}
533
534impl Response {
535    /// Get the data content as a string slice
536    ///
537    /// This is a convenience method similar to Ribbit.NET's `ToString()`
538    #[must_use]
539    pub fn as_text(&self) -> Option<&str> {
540        self.data.as_deref()
541    }
542
543    /// Parse the response data as BPSV
544    ///
545    /// This allows direct access to the BPSV document structure.
546    /// Note: This method adjusts HEX field lengths for Blizzard's format.
547    ///
548    /// # Errors
549    /// Returns an error if the response has no data or BPSV parsing fails.
550    pub fn as_bpsv(&self) -> Result<ngdp_bpsv::BpsvDocument> {
551        match &self.data {
552            Some(data) => {
553                // Parse directly - BPSV parser now correctly handles HEX:N as N bytes
554                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    /// Parse a V1 (MIME) response
564    #[allow(clippy::too_many_lines)]
565    fn parse_v1(raw: &[u8]) -> Result<Self> {
566        // First, check if there's a checksum at the end of the raw data
567        let (_, checksum) = Self::extract_checksum(raw);
568        debug!("Extracted checksum from V1 response: {checksum:?}");
569
570        // Parse the full MIME message (including any epilogue with checksum)
571        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        // For checksum validation, we need to validate against the message without checksum
576        if let Some(expected_checksum) = &checksum {
577            // Extract the message bytes without checksum for validation
578            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        // Extract the main data part and signature
589        let mut data_content = None;
590        let mut signature_content = None;
591
592        // Look for multipart content
593        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            // Debug headers
598            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            // Check Content-Disposition header
610            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            // Get the text content for data parts
627            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                // Get content for signature - it might be text or binary
639                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                        // The signature is likely base64 encoded
645                        let text_str = text.as_ref().trim();
646                        // Try to decode base64
647                        match STANDARD.decode(text_str) {
648                            Ok(decoded) => signature_content = Some(decoded),
649                            Err(_) => {
650                                // Try as raw bytes if not base64
651                                signature_content = Some(text.as_bytes().to_vec());
652                            }
653                        }
654                    }
655                    _ => {}
656                }
657            }
658        }
659
660        // If no multipart, try to get the main body
661        if data_content.is_none() {
662            // Check if we have text body indices
663            if !message.text_body.is_empty() {
664                // Try to get text from the first text body index
665                if let Some(text) = message.body_text(0) {
666                    data_content = Some(text.to_string());
667                }
668            }
669
670            // If still no content, extract from raw message
671            if data_content.is_none() {
672                let raw_msg = message.raw_message.as_ref();
673                // Find the double CRLF that separates headers from body
674                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                    // Trim any trailing whitespace
678                    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                // Parse signature if present
686                let (signature_info, signature_verification) =
687                    if let Some(ref sig_bytes) = signature_content {
688                        // For signature verification, use the data without checksum
689                        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                        // Try enhanced parsing first
697                        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                                // Convert to basic SignatureInfo for backward compatibility
704                                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                                // Fall back to basic parsing
716                                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    /// Parse a V2 (raw PSV) response
751    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    /// Extract checksum from the epilogue of a V1 response
761    fn extract_checksum(raw: &[u8]) -> (&[u8], Option<String>) {
762        const CHECKSUM_PREFIX: &[u8] = b"Checksum: ";
763
764        // Look for the last occurrence of "Checksum: " in the data
765        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            // Found "Checksum: " - extract the rest of the line
771            let checksum_line_start = checksum_pos;
772
773            // Find the end of the line (newline character)
774            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            // Extract just the hex part (after "Checksum: " and before newline)
780            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            // Also strip carriage return if present
788            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                // Validate it's a proper hex string
795                if checksum.len() == 64 && checksum.chars().all(|c| c.is_ascii_hexdigit()) {
796                    trace!("Valid checksum found: {checksum}");
797                    // Return the message without the checksum line
798                    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        // No valid checksum found
807        let len = raw.len();
808        trace!("No checksum found in {len} bytes of data");
809        (raw, None)
810    }
811
812    /// Validate the SHA-256 checksum of the message
813    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        // Use a non-routable IP address to ensure timeout
883        let client = RibbitClient::new(Region::CN);
884        let result = client.request_raw(&Endpoint::Summary).await;
885
886        // The CN region often times out from outside China
887        // This test may pass or fail depending on network conditions
888        // but we're mainly testing that the timeout mechanism works
889        if result.is_err() {
890            let err = result.unwrap_err();
891            // Check if it's either a connection timeout or connection failed
892            match err {
893                crate::error::Error::ConnectionTimeout { .. }
894                | crate::error::Error::ConnectionFailed { .. } => {
895                    // Expected for CN region from most locations
896                    // Connection might fail or timeout before completion
897                }
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        // Test with valid checksum
916        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        // Test without checksum
926        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        // Test data
938        let message = b"test message";
939
940        // Compute expected checksum
941        let mut hasher = Sha256::new();
942        hasher.update(message);
943        let expected = format!("{:x}", hasher.finalize());
944
945        // Should succeed with correct checksum
946        assert!(Response::validate_checksum(message, &expected).is_ok());
947
948        // Should fail with incorrect checksum
949        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        // Create a simple MIME message
956        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        // Create MIME with checksum
977        let mime_data =
978            concat!("Content-Type: text/plain\r\n", "\r\n", "test data\r\n",).as_bytes();
979
980        // Add checksum
981        let mut data_with_checksum = mime_data.to_vec();
982
983        // Calculate real checksum
984        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        // Create a multipart MIME message similar to what the server returns
997        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        // This will fail validation because the checksum is fake, but we can check it was extracted
1013        if let Err(crate::error::Error::ChecksumMismatch) = response {
1014            // This is expected - the checksum was found but doesn't match
1015            // Test passes because we successfully found and tried to validate the checksum
1016        } 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        // Create a multipart MIME message with a signature attachment
1026        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        // This is a minimal PKCS#7 signedData structure
1041        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        // The signature might have been base64 encoded, check its actual length
1055        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        // For now, just check that we got a signature
1062        // The parsing might fail on this minimal test data
1063        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); // Should be clamped to 1.0
1088
1089        let client2 = RibbitClient::new(Region::US).with_jitter_factor(-0.5);
1090        assert!((client2.jitter_factor - 0.0).abs() < f64::EPSILON); // Should be clamped to 0.0
1091    }
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); // No jitter for predictable test
1100
1101        // Test exponential backoff
1102        let backoff0 = client.calculate_backoff(0);
1103        assert_eq!(backoff0.as_millis(), 100); // 100ms * 2^0 = 100ms
1104
1105        let backoff1 = client.calculate_backoff(1);
1106        assert_eq!(backoff1.as_millis(), 200); // 100ms * 2^1 = 200ms
1107
1108        let backoff2 = client.calculate_backoff(2);
1109        assert_eq!(backoff2.as_millis(), 400); // 100ms * 2^2 = 400ms
1110
1111        // Test max backoff capping
1112        let backoff5 = client.calculate_backoff(5);
1113        assert_eq!(backoff5.as_millis(), 1000); // Would be 3200ms but capped at 1000ms
1114    }
1115
1116    #[test]
1117    fn test_default_retry_configuration() {
1118        let client = RibbitClient::new(Region::US);
1119        assert_eq!(client.max_retries, 0); // Default should be 0 for backward compatibility
1120    }
1121}