veracode_platform/
client.rs

1//! Core Veracode API client implementation.
2//!
3//! This module contains the foundational client for making authenticated requests
4//! to the Veracode API, including HMAC authentication and HTTP request handling.
5
6use bytes::Bytes;
7use hex;
8use hmac::{Hmac, Mac};
9use log::{info, warn};
10use reqwest::{Body, Client, multipart};
11use secrecy::ExposeSecret;
12use serde::Serialize;
13use sha2::Sha256;
14use std::borrow::Cow;
15use std::collections::HashMap;
16use std::fs::File;
17use std::io::{Read, Seek, SeekFrom};
18use std::sync::Arc;
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20use tokio::fs::File as TokioFile;
21use url::Url;
22
23use crate::json_validator::{MAX_JSON_DEPTH, validate_json_depth};
24use crate::{VeracodeConfig, VeracodeError};
25
26// Type aliases for HMAC
27type HmacSha256 = Hmac<Sha256>;
28
29// Constants for authentication error messages to avoid repeated allocations
30const INVALID_URL_MSG: &str = "Invalid URL";
31const INVALID_API_KEY_MSG: &str = "Invalid API key format - must be hex string";
32const INVALID_NONCE_MSG: &str = "Invalid nonce format";
33const HMAC_CREATION_FAILED_MSG: &str = "Failed to create HMAC";
34
35/// Core Veracode API client.
36///
37/// This struct provides the foundational HTTP client with HMAC authentication
38/// for making requests to any Veracode API endpoint.
39#[derive(Clone)]
40pub struct VeracodeClient {
41    config: VeracodeConfig,
42    client: Client,
43}
44
45impl VeracodeClient {
46    /// Build URL with query parameters - centralized helper
47    fn build_url_with_params(&self, endpoint: &str, query_params: &[(&str, &str)]) -> String {
48        // Pre-allocate string capacity for better performance
49        let estimated_capacity = self
50            .config
51            .base_url
52            .len()
53            .saturating_add(endpoint.len())
54            .saturating_add(query_params.len().saturating_mul(32)); // Rough estimate for query params
55
56        let mut url = String::with_capacity(estimated_capacity);
57        url.push_str(&self.config.base_url);
58        url.push_str(endpoint);
59
60        if !query_params.is_empty() {
61            url.push('?');
62            for (i, (key, value)) in query_params.iter().enumerate() {
63                if i > 0 {
64                    url.push('&');
65                }
66                url.push_str(&urlencoding::encode(key));
67                url.push('=');
68                url.push_str(&urlencoding::encode(value));
69            }
70        }
71
72        url
73    }
74
75    /// Create a new Veracode API client.
76    ///
77    /// # Arguments
78    ///
79    /// * `config` - Configuration containing API credentials and settings
80    ///
81    /// # Returns
82    ///
83    /// A new `VeracodeClient` instance ready to make API calls.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the API request fails, the resource is not found,
88    /// or authentication/authorization fails.
89    pub fn new(config: VeracodeConfig) -> Result<Self, VeracodeError> {
90        let mut client_builder = Client::builder();
91
92        // Use the certificate validation setting from config
93        if !config.validate_certificates {
94            client_builder = client_builder
95                .danger_accept_invalid_certs(true)
96                .danger_accept_invalid_hostnames(true);
97        }
98
99        // Configure HTTP timeouts from config
100        client_builder = client_builder
101            .connect_timeout(Duration::from_secs(config.connect_timeout))
102            .timeout(Duration::from_secs(config.request_timeout));
103
104        // Configure proxy if specified
105        if let Some(proxy_url) = &config.proxy_url {
106            let mut proxy = reqwest::Proxy::all(proxy_url)
107                .map_err(|e| VeracodeError::InvalidConfig(format!("Invalid proxy URL: {e}")))?;
108
109            // Add basic authentication if credentials are provided
110            if let (Some(username), Some(password)) =
111                (&config.proxy_username, &config.proxy_password)
112            {
113                proxy = proxy.basic_auth(username.expose_secret(), password.expose_secret());
114            }
115
116            client_builder = client_builder.proxy(proxy);
117        }
118
119        let client = client_builder.build().map_err(VeracodeError::Http)?;
120        Ok(Self { config, client })
121    }
122
123    /// Get the base URL for API requests.
124    #[must_use]
125    pub fn base_url(&self) -> &str {
126        &self.config.base_url
127    }
128
129    /// Get access to the configuration
130    #[must_use]
131    pub fn config(&self) -> &VeracodeConfig {
132        &self.config
133    }
134
135    /// Get access to the underlying reqwest client
136    #[must_use]
137    pub fn client(&self) -> &Client {
138        &self.client
139    }
140
141    /// Execute an HTTP request with retry logic and exponential backoff.
142    ///
143    /// This method implements the retry strategy defined in the client's configuration.
144    /// It will retry requests that fail due to transient errors (network issues,
145    /// server errors, rate limiting) using exponential backoff. For rate limiting (429),
146    /// it uses intelligent delays based on Veracode's minute-window rate limits.
147    ///
148    /// # Arguments
149    ///
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the API request fails, the resource is not found,
154    /// or authentication/authorization fails.
155    /// * `request_builder` - A closure that creates the `reqwest::RequestBuilder`
156    /// * `operation_name` - A human-readable name for logging/error messages
157    ///
158    /// # Returns
159    ///
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if the API request fails, the resource is not found,
164    /// or authentication/authorization fails.
165    /// A `Result` containing the HTTP response or a `VeracodeError`.
166    async fn execute_with_retry<F>(
167        &self,
168        request_builder: F,
169        operation_name: Cow<'_, str>,
170    ) -> Result<reqwest::Response, VeracodeError>
171    where
172        F: Fn() -> reqwest::RequestBuilder,
173    {
174        let retry_config = &self.config.retry_config;
175        let start_time = Instant::now();
176        let mut total_delay = std::time::Duration::from_millis(0);
177
178        // If retries are disabled, make a single attempt
179        if retry_config.max_attempts == 0 {
180            return match request_builder().send().await {
181                Ok(response) => Ok(response),
182                Err(e) => Err(VeracodeError::Http(e)),
183            };
184        }
185
186        let mut last_error = None;
187        let mut rate_limit_attempts: u32 = 0;
188
189        for attempt in 1..=retry_config.max_attempts.saturating_add(1) {
190            // Build and send the request
191            match request_builder().send().await {
192                Ok(response) => {
193                    // Check for rate limiting before treating as success
194                    if response.status().as_u16() == 429 {
195                        // Extract Retry-After header if present
196                        let retry_after_seconds = response
197                            .headers()
198                            .get("retry-after")
199                            .and_then(|h| h.to_str().ok())
200                            .and_then(|s| s.parse::<u64>().ok());
201
202                        let message = "HTTP 429: Rate limit exceeded".to_string();
203                        let veracode_error = VeracodeError::RateLimited {
204                            retry_after_seconds,
205                            message,
206                        };
207
208                        // Increment rate limit attempt counter
209                        rate_limit_attempts = rate_limit_attempts.saturating_add(1);
210
211                        // Check if we should retry based on rate limit specific limits
212                        if attempt > retry_config.max_attempts
213                            || rate_limit_attempts > retry_config.rate_limit_max_attempts
214                        {
215                            last_error = Some(veracode_error);
216                            break;
217                        }
218
219                        // Calculate rate limit specific delay
220                        let delay = retry_config.calculate_rate_limit_delay(retry_after_seconds);
221                        total_delay = total_delay.saturating_add(delay);
222
223                        // Check total delay limit
224                        if total_delay.as_millis() > retry_config.max_total_delay_ms as u128 {
225                            let msg = format!(
226                                "{} exceeded maximum total retry time of {}ms after {} attempts",
227                                operation_name, retry_config.max_total_delay_ms, attempt
228                            );
229                            last_error = Some(VeracodeError::RetryExhausted(msg));
230                            break;
231                        }
232
233                        // Log rate limit with specific formatting
234                        let wait_time = match retry_after_seconds {
235                            Some(seconds) => format!("{seconds}s (from Retry-After header)"),
236                            None => format!("{}s (until next minute window)", delay.as_secs()),
237                        };
238                        warn!(
239                            "🚦 {operation_name} rate limited on attempt {attempt}, waiting {wait_time}"
240                        );
241
242                        // Wait and continue to next attempt
243                        tokio::time::sleep(delay).await;
244                        last_error = Some(veracode_error);
245                        continue;
246                    }
247
248                    if attempt > 1 {
249                        // Log successful retry for debugging
250                        info!("βœ… {operation_name} succeeded on attempt {attempt}");
251                    }
252                    return Ok(response);
253                }
254                Err(e) => {
255                    // For connection errors, network issues, etc., use normal retry logic
256                    let veracode_error = VeracodeError::Http(e);
257
258                    // Check if this is the last attempt or if the error is not retryable
259                    if attempt > retry_config.max_attempts
260                        || !retry_config.is_retryable_error(&veracode_error)
261                    {
262                        last_error = Some(veracode_error);
263                        break;
264                    }
265
266                    // Use normal exponential backoff for non-429 errors
267                    let delay = retry_config.calculate_delay(attempt);
268                    total_delay = total_delay.saturating_add(delay);
269
270                    // Check if we've exceeded the maximum total delay
271                    if total_delay.as_millis() > retry_config.max_total_delay_ms as u128 {
272                        // Format error message once
273                        let msg = format!(
274                            "{} exceeded maximum total retry time of {}ms after {} attempts",
275                            operation_name, retry_config.max_total_delay_ms, attempt
276                        );
277                        last_error = Some(VeracodeError::RetryExhausted(msg));
278                        break;
279                    }
280
281                    // Log retry attempt for debugging
282                    warn!(
283                        "⚠️  {operation_name} failed on attempt {attempt}, retrying in {}ms: {veracode_error}",
284                        delay.as_millis()
285                    );
286
287                    // Wait before next attempt
288                    tokio::time::sleep(delay).await;
289                    last_error = Some(veracode_error);
290                }
291            }
292        }
293
294        // All attempts failed - create error message efficiently
295        match last_error {
296            Some(error) => {
297                let elapsed = start_time.elapsed();
298                match error {
299                    VeracodeError::RetryExhausted(_) => Err(error),
300                    VeracodeError::Http(_)
301                    | VeracodeError::Serialization(_)
302                    | VeracodeError::Authentication(_)
303                    | VeracodeError::InvalidResponse(_)
304                    | VeracodeError::InvalidConfig(_)
305                    | VeracodeError::NotFound(_)
306                    | VeracodeError::RateLimited { .. }
307                    | VeracodeError::Validation(_) => {
308                        let msg = format!(
309                            "{} failed after {} attempts over {}ms: {}",
310                            operation_name,
311                            retry_config.max_attempts.saturating_add(1),
312                            elapsed.as_millis(),
313                            error
314                        );
315                        Err(VeracodeError::RetryExhausted(msg))
316                    }
317                }
318            }
319            None => {
320                let msg = format!(
321                    "{} failed after {} attempts with unknown error",
322                    operation_name,
323                    retry_config.max_attempts.saturating_add(1)
324                );
325                Err(VeracodeError::RetryExhausted(msg))
326            }
327        }
328    }
329
330    /// Generate HMAC signature for authentication based on official Veracode JavaScript implementation
331    fn generate_hmac_signature(
332        &self,
333        method: &str,
334        url: &str,
335        timestamp: u64,
336        nonce: &str,
337    ) -> Result<String, VeracodeError> {
338        let url_parsed = Url::parse(url)
339            .map_err(|_| VeracodeError::Authentication(INVALID_URL_MSG.to_string()))?;
340
341        let path_and_query = match url_parsed.query() {
342            Some(query) => format!("{}?{}", url_parsed.path(), query),
343            None => url_parsed.path().to_string(),
344        };
345
346        let host = url_parsed.host_str().unwrap_or("");
347
348        // Based on the official Veracode JavaScript implementation:
349        // var data = `id=${id}&host=${host}&url=${url}&method=${method}`;
350        let data = format!(
351            "id={}&host={}&url={}&method={}",
352            self.config.credentials.expose_api_id(),
353            host,
354            path_and_query,
355            method
356        );
357
358        let timestamp_str = timestamp.to_string();
359        let ver_str = "vcode_request_version_1";
360
361        // Convert hex strings to bytes
362        let key_bytes = hex::decode(self.config.credentials.expose_api_key())
363            .map_err(|_| VeracodeError::Authentication(INVALID_API_KEY_MSG.to_string()))?;
364
365        let nonce_bytes = hex::decode(nonce)
366            .map_err(|_| VeracodeError::Authentication(INVALID_NONCE_MSG.to_string()))?;
367
368        // Step 1: HMAC(nonce, key)
369        let mut mac1 = HmacSha256::new_from_slice(&key_bytes)
370            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
371        mac1.update(&nonce_bytes);
372        let hashed_nonce = mac1.finalize().into_bytes();
373
374        // Step 2: HMAC(timestamp, hashed_nonce)
375        let mut mac2 = HmacSha256::new_from_slice(&hashed_nonce)
376            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
377        mac2.update(timestamp_str.as_bytes());
378        let hashed_timestamp = mac2.finalize().into_bytes();
379
380        // Step 3: HMAC(ver_str, hashed_timestamp)
381        let mut mac3 = HmacSha256::new_from_slice(&hashed_timestamp)
382            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
383        mac3.update(ver_str.as_bytes());
384        let hashed_ver_str = mac3.finalize().into_bytes();
385
386        // Step 4: HMAC(data, hashed_ver_str)
387        let mut mac4 = HmacSha256::new_from_slice(&hashed_ver_str)
388            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
389        mac4.update(data.as_bytes());
390        let signature = mac4.finalize().into_bytes();
391
392        // Return the hex-encoded signature (lowercase)
393        Ok(hex::encode(signature).to_lowercase())
394    }
395
396    /// Generate authorization header for HMAC authentication
397    ///
398    /// # Errors
399    ///
400    /// Returns an error if the API request fails, the resource is not found,
401    /// or authentication/authorization fails.
402    pub fn generate_auth_header(&self, method: &str, url: &str) -> Result<String, VeracodeError> {
403        #[allow(clippy::cast_possible_truncation)]
404        let timestamp = SystemTime::now()
405            .duration_since(UNIX_EPOCH)
406            .map_err(|e| VeracodeError::Authentication(format!("System time error: {e}")))?
407            .as_millis() as u64; // Use milliseconds like JavaScript
408
409        // Generate a 16-byte random nonce and convert to hex string
410        let nonce_bytes: [u8; 16] = rand::random();
411        let nonce = hex::encode(nonce_bytes);
412
413        let signature = self.generate_hmac_signature(method, url, timestamp, &nonce)?;
414
415        Ok(format!(
416            "VERACODE-HMAC-SHA-256 id={},ts={},nonce={},sig={}",
417            self.config.credentials.expose_api_id(),
418            timestamp,
419            nonce,
420            signature
421        ))
422    }
423
424    /// Make a GET request to the specified endpoint.
425    ///
426    /// # Arguments
427    ///
428    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications")
429    /// * `query_params` - Optional query parameters as key-value pairs
430    ///
431    /// # Returns
432    ///
433    /// A `Result` containing the HTTP response.
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the API request fails, the resource is not found,
438    /// or authentication/authorization fails.
439    pub async fn get(
440        &self,
441        endpoint: &str,
442        query_params: Option<&[(String, String)]>,
443    ) -> Result<reqwest::Response, VeracodeError> {
444        // Pre-allocate URL capacity
445        let param_count = query_params.map_or(0, |p| p.len());
446        let estimated_capacity = self
447            .config
448            .base_url
449            .len()
450            .saturating_add(endpoint.len())
451            .saturating_add(param_count.saturating_mul(32));
452        let mut url = String::with_capacity(estimated_capacity);
453        url.push_str(&self.config.base_url);
454        url.push_str(endpoint);
455
456        if let Some(params) = query_params
457            && !params.is_empty()
458        {
459            url.push('?');
460            for (i, (key, value)) in params.iter().enumerate() {
461                if i > 0 {
462                    url.push('&');
463                }
464                url.push_str(key);
465                url.push('=');
466                url.push_str(value);
467            }
468        }
469
470        // Create request builder closure for retry logic
471        let request_builder = || {
472            // Re-generate auth header for each attempt to avoid signature expiry
473            let Ok(auth_header) = self.generate_auth_header("GET", &url) else {
474                return self.client.get("invalid://url");
475            };
476
477            self.client
478                .get(&url)
479                .header("Authorization", auth_header)
480                .header("Content-Type", "application/json")
481        };
482
483        // Use Cow::Borrowed for simple operations when possible
484        let operation_name = if endpoint.len() < 50 {
485            Cow::Owned(format!("GET {endpoint}"))
486        } else {
487            Cow::Borrowed("GET [long endpoint]")
488        };
489        self.execute_with_retry(request_builder, operation_name)
490            .await
491    }
492
493    /// Make a POST request to the specified endpoint.
494    ///
495    /// # Arguments
496    ///
497    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications")
498    /// * `body` - Optional request body that implements Serialize
499    ///
500    /// # Returns
501    ///
502    /// A `Result` containing the HTTP response.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if the API request fails, the resource is not found,
507    /// or authentication/authorization fails.
508    pub async fn post<T: Serialize>(
509        &self,
510        endpoint: &str,
511        body: Option<&T>,
512    ) -> Result<reqwest::Response, VeracodeError> {
513        let mut url =
514            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
515        url.push_str(&self.config.base_url);
516        url.push_str(endpoint);
517
518        // Serialize body once outside the retry loop for efficiency
519        let serialized_body = if let Some(body) = body {
520            Some(serde_json::to_string(body)?)
521        } else {
522            None
523        };
524
525        // Create request builder closure for retry logic
526        let request_builder = || {
527            // Re-generate auth header for each attempt to avoid signature expiry
528            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
529                return self.client.post("invalid://url");
530            };
531
532            let mut request = self
533                .client
534                .post(&url)
535                .header("Authorization", auth_header)
536                .header("Content-Type", "application/json");
537
538            if let Some(ref body_str) = serialized_body {
539                request = request.body(body_str.clone());
540            }
541
542            request
543        };
544
545        let operation_name = if endpoint.len() < 50 {
546            Cow::Owned(format!("POST {endpoint}"))
547        } else {
548            Cow::Borrowed("POST [long endpoint]")
549        };
550        self.execute_with_retry(request_builder, operation_name)
551            .await
552    }
553
554    /// Make a PUT request to the specified endpoint.
555    ///
556    /// # Arguments
557    ///
558    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications/guid")
559    /// * `body` - Optional request body that implements Serialize
560    ///
561    /// # Returns
562    ///
563    /// A `Result` containing the HTTP response.
564    ///
565    /// # Errors
566    ///
567    /// Returns an error if the API request fails, the resource is not found,
568    /// or authentication/authorization fails.
569    pub async fn put<T: Serialize>(
570        &self,
571        endpoint: &str,
572        body: Option<&T>,
573    ) -> Result<reqwest::Response, VeracodeError> {
574        let mut url =
575            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
576        url.push_str(&self.config.base_url);
577        url.push_str(endpoint);
578
579        // Serialize body once outside the retry loop for efficiency
580        let serialized_body = if let Some(body) = body {
581            Some(serde_json::to_string(body)?)
582        } else {
583            None
584        };
585
586        // Create request builder closure for retry logic
587        let request_builder = || {
588            // Re-generate auth header for each attempt to avoid signature expiry
589            let Ok(auth_header) = self.generate_auth_header("PUT", &url) else {
590                return self.client.put("invalid://url");
591            };
592
593            let mut request = self
594                .client
595                .put(&url)
596                .header("Authorization", auth_header)
597                .header("Content-Type", "application/json");
598
599            if let Some(ref body_str) = serialized_body {
600                request = request.body(body_str.clone());
601            }
602
603            request
604        };
605
606        let operation_name = if endpoint.len() < 50 {
607            Cow::Owned(format!("PUT {endpoint}"))
608        } else {
609            Cow::Borrowed("PUT [long endpoint]")
610        };
611        self.execute_with_retry(request_builder, operation_name)
612            .await
613    }
614
615    /// Make a DELETE request to the specified endpoint.
616    ///
617    /// # Arguments
618    ///
619    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications/guid")
620    ///
621    /// # Returns
622    ///
623    /// A `Result` containing the HTTP response.
624    ///
625    /// # Errors
626    ///
627    /// Returns an error if the API request fails, the resource is not found,
628    /// or authentication/authorization fails.
629    pub async fn delete(&self, endpoint: &str) -> Result<reqwest::Response, VeracodeError> {
630        let mut url =
631            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
632        url.push_str(&self.config.base_url);
633        url.push_str(endpoint);
634
635        // Create request builder closure for retry logic
636        let request_builder = || {
637            // Re-generate auth header for each attempt to avoid signature expiry
638            let Ok(auth_header) = self.generate_auth_header("DELETE", &url) else {
639                return self.client.delete("invalid://url");
640            };
641
642            self.client
643                .delete(&url)
644                .header("Authorization", auth_header)
645                .header("Content-Type", "application/json")
646        };
647
648        let operation_name = if endpoint.len() < 50 {
649            Cow::Owned(format!("DELETE {endpoint}"))
650        } else {
651            Cow::Borrowed("DELETE [long endpoint]")
652        };
653        self.execute_with_retry(request_builder, operation_name)
654            .await
655    }
656
657    /// Helper method to handle common response processing.
658    ///
659    /// Checks if the response is successful and returns an error if not.
660    ///
661    /// # Arguments
662    ///
663    /// * `response` - The HTTP response to check
664    /// * `context` - A description of the operation being performed (e.g., "get application")
665    ///
666    /// # Returns
667    ///
668    /// A `Result` containing the response if successful, or an error if not.
669    ///
670    /// # Error Context
671    ///
672    /// This method enhances error messages with context about the failed operation
673    /// to improve debugging and user experience.
674    ///
675    /// # Errors
676    ///
677    /// Returns an error if the API request fails, the resource is not found,
678    /// or authentication/authorization fails.
679    pub async fn handle_response(
680        response: reqwest::Response,
681        context: &str,
682    ) -> Result<reqwest::Response, VeracodeError> {
683        if !response.status().is_success() {
684            let status = response.status();
685            let url = response.url().clone();
686            let error_text = response.text().await?;
687            return Err(VeracodeError::InvalidResponse(format!(
688                "Failed to {context}\n  URL: {url}\n  HTTP {status}: {error_text}"
689            )));
690        }
691        Ok(response)
692    }
693
694    /// Make a GET request with full URL construction and query parameter handling.
695    ///
696    /// This is a higher-level method that builds the full URL and handles query parameters.
697    ///
698    /// # Arguments
699    ///
700    /// * `endpoint` - The API endpoint path
701    /// * `query_params` - Optional query parameters
702    ///
703    /// # Returns
704    ///
705    /// A `Result` containing the HTTP response, pre-processed for success/failure.
706    ///
707    /// # Errors
708    ///
709    /// Returns an error if the API request fails, the resource is not found,
710    /// or authentication/authorization fails.
711    pub async fn get_with_query(
712        &self,
713        endpoint: &str,
714        query_params: Option<Vec<(String, String)>>,
715    ) -> Result<reqwest::Response, VeracodeError> {
716        let query_slice = query_params.as_deref();
717        let response = self.get(endpoint, query_slice).await?;
718        Self::handle_response(response, &format!("GET {endpoint}")).await
719    }
720
721    /// Make a POST request with automatic response handling.
722    ///
723    /// # Arguments
724    ///
725    /// * `endpoint` - The API endpoint path
726    /// * `body` - Optional request body
727    ///
728    /// # Returns
729    ///
730    /// A `Result` containing the HTTP response, pre-processed for success/failure.
731    ///
732    /// # Errors
733    ///
734    /// Returns an error if the API request fails, the resource is not found,
735    /// or authentication/authorization fails.
736    pub async fn post_with_response<T: Serialize>(
737        &self,
738        endpoint: &str,
739        body: Option<&T>,
740    ) -> Result<reqwest::Response, VeracodeError> {
741        let response = self.post(endpoint, body).await?;
742        Self::handle_response(response, &format!("POST {endpoint}")).await
743    }
744
745    /// Make a PUT request with automatic response handling.
746    ///
747    /// # Arguments
748    ///
749    /// * `endpoint` - The API endpoint path
750    /// * `body` - Optional request body
751    ///
752    /// # Returns
753    ///
754    /// A `Result` containing the HTTP response, pre-processed for success/failure.
755    ///
756    /// # Errors
757    ///
758    /// Returns an error if the API request fails, the resource is not found,
759    /// or authentication/authorization fails.
760    pub async fn put_with_response<T: Serialize>(
761        &self,
762        endpoint: &str,
763        body: Option<&T>,
764    ) -> Result<reqwest::Response, VeracodeError> {
765        let response = self.put(endpoint, body).await?;
766        Self::handle_response(response, &format!("PUT {endpoint}")).await
767    }
768
769    /// Make a DELETE request with automatic response handling.
770    ///
771    /// # Arguments
772    ///
773    /// * `endpoint` - The API endpoint path
774    ///
775    /// # Returns
776    ///
777    /// A `Result` containing the HTTP response, pre-processed for success/failure.
778    ///
779    /// # Errors
780    ///
781    /// Returns an error if the API request fails, the resource is not found,
782    /// or authentication/authorization fails.
783    pub async fn delete_with_response(
784        &self,
785        endpoint: &str,
786    ) -> Result<reqwest::Response, VeracodeError> {
787        let response = self.delete(endpoint).await?;
788        Self::handle_response(response, &format!("DELETE {endpoint}")).await
789    }
790
791    /// Make paginated GET requests to collect all results.
792    ///
793    /// This method automatically handles pagination by making multiple requests
794    /// and combining all results into a single response.
795    ///
796    /// # Arguments
797    ///
798    /// * `endpoint` - The API endpoint path
799    /// * `base_query_params` - Base query parameters (non-pagination)
800    /// * `page_size` - Number of items per page (default: 500)
801    ///
802    /// # Returns
803    ///
804    /// A `Result` containing all paginated results as a single response body string.
805    ///
806    /// # Errors
807    ///
808    /// Returns an error if the API request fails, the resource is not found,
809    /// or authentication/authorization fails.
810    pub async fn get_paginated(
811        &self,
812        endpoint: &str,
813        base_query_params: Option<Vec<(String, String)>>,
814        page_size: Option<u32>,
815    ) -> Result<String, VeracodeError> {
816        let size = page_size.unwrap_or(500);
817        let mut page: u32 = 0;
818        let mut all_items = Vec::new();
819        let mut page_info = None;
820
821        loop {
822            let mut query_params = base_query_params.clone().unwrap_or_default();
823            query_params.push(("page".to_string(), page.to_string()));
824            query_params.push(("size".to_string(), size.to_string()));
825
826            let response = self.get_with_query(endpoint, Some(query_params)).await?;
827            let response_text = response.text().await?;
828
829            // Validate JSON depth before parsing to prevent DoS attacks
830            validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
831                VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e))
832            })?;
833
834            // Try to parse as JSON to extract items and pagination info
835            if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
836                // Handle embedded response format
837                if let Some(embedded) = json_value.get("_embedded") {
838                    if let Some(items_array) =
839                        embedded.as_object().and_then(|obj| obj.values().next())
840                        && let Some(items) = items_array.as_array()
841                    {
842                        if items.is_empty() {
843                            break; // No more items
844                        }
845                        all_items.extend(items.clone());
846                    }
847                } else if let Some(items) = json_value.as_array() {
848                    // Handle direct array response
849                    if items.is_empty() {
850                        break;
851                    }
852                    all_items.extend(items.clone());
853                } else {
854                    // Single page response, return as-is
855                    return Ok(response_text);
856                }
857
858                // Check pagination info
859                if let Some(page_obj) = json_value.get("page") {
860                    page_info = Some(page_obj.clone());
861                    if let (Some(current), Some(total)) = (
862                        page_obj.get("number").and_then(|n| n.as_u64()),
863                        page_obj.get("totalPages").and_then(|n| n.as_u64()),
864                    ) && current.saturating_add(1) >= total
865                    {
866                        break; // Last page reached
867                    }
868                }
869            } else {
870                // Not JSON or parsing failed, return single response
871                return Ok(response_text);
872            }
873
874            page = page.saturating_add(1);
875
876            // Safety check to prevent infinite loops
877            if page > 100 {
878                break;
879            }
880        }
881
882        // Reconstruct response with all items
883        let combined_response = if let Some(page_info) = page_info {
884            // Use embedded format
885            serde_json::json!({
886                "_embedded": {
887                    "roles": all_items // This key might need to be dynamic
888                },
889                "page": page_info
890            })
891        } else {
892            // Use direct array format
893            serde_json::Value::Array(all_items)
894        };
895
896        Ok(combined_response.to_string())
897    }
898
899    /// Make a GET request with query parameters
900    ///
901    /// # Arguments
902    ///
903    /// * `endpoint` - The API endpoint to call
904    /// * `params` - Query parameters as a slice of tuples
905    ///
906    /// # Returns
907    ///
908    /// A `Result` containing the response or an error.
909    ///
910    /// # Errors
911    ///
912    /// Returns an error if the API request fails, the resource is not found,
913    /// or authentication/authorization fails.
914    pub async fn get_with_params(
915        &self,
916        endpoint: &str,
917        params: &[(&str, &str)],
918    ) -> Result<reqwest::Response, VeracodeError> {
919        let mut url =
920            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
921        url.push_str(&self.config.base_url);
922        url.push_str(endpoint);
923        let mut request_url =
924            Url::parse(&url).map_err(|e| VeracodeError::InvalidConfig(e.to_string()))?;
925
926        // Add query parameters
927        if !params.is_empty() {
928            let mut query_pairs = request_url.query_pairs_mut();
929            for (key, value) in params {
930                query_pairs.append_pair(key, value);
931            }
932        }
933
934        let auth_header = self.generate_auth_header("GET", request_url.as_str())?;
935
936        let response = self
937            .client
938            .get(request_url)
939            .header("Authorization", auth_header)
940            .header("User-Agent", "Veracode Rust Client")
941            .send()
942            .await?;
943
944        Ok(response)
945    }
946
947    /// Make a POST request with form data
948    ///
949    /// # Arguments
950    ///
951    /// * `endpoint` - The API endpoint to call
952    /// * `params` - Form parameters as a slice of tuples
953    ///
954    /// # Returns
955    ///
956    /// A `Result` containing the response or an error.
957    ///
958    /// # Errors
959    ///
960    /// Returns an error if the API request fails, the resource is not found,
961    /// or authentication/authorization fails.
962    pub async fn post_form(
963        &self,
964        endpoint: &str,
965        params: &[(&str, &str)],
966    ) -> Result<reqwest::Response, VeracodeError> {
967        let mut url =
968            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
969        url.push_str(&self.config.base_url);
970        url.push_str(endpoint);
971
972        // Build form data - avoid unnecessary allocations
973        let form_data: Vec<(&str, &str)> = params.to_vec();
974
975        let auth_header = self.generate_auth_header("POST", &url)?;
976
977        let response = self
978            .client
979            .post(&url)
980            .header("Authorization", auth_header)
981            .header("User-Agent", "Veracode Rust Client")
982            .form(&form_data)
983            .send()
984            .await?;
985
986        Ok(response)
987    }
988
989    /// Upload a file using multipart form data
990    ///
991    /// # Arguments
992    ///
993    /// * `endpoint` - The API endpoint to call
994    /// * `params` - Additional form parameters
995    /// * `file_field_name` - Name of the file field
996    /// * `filename` - Name of the file
997    /// * `file_data` - File data as bytes
998    ///
999    /// # Returns
1000    ///
1001    /// A `Result` containing the response or an error.
1002    ///
1003    /// # Errors
1004    ///
1005    /// Returns an error if the API request fails, the resource is not found,
1006    /// or authentication/authorization fails.
1007    pub async fn upload_file_multipart(
1008        &self,
1009        endpoint: &str,
1010        params: HashMap<&str, &str>,
1011        file_field_name: &str,
1012        filename: &str,
1013        file_data: Vec<u8>,
1014    ) -> Result<reqwest::Response, VeracodeError> {
1015        let mut url =
1016            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
1017        url.push_str(&self.config.base_url);
1018        url.push_str(endpoint);
1019
1020        // Build multipart form
1021        let mut form = multipart::Form::new();
1022
1023        // Add regular form fields
1024        for (key, value) in params {
1025            form = form.text(key.to_string(), value.to_string());
1026        }
1027
1028        // Add file
1029        let part = multipart::Part::bytes(file_data)
1030            .file_name(filename.to_string())
1031            .mime_str("application/octet-stream")
1032            .map_err(|e| VeracodeError::InvalidConfig(e.to_string()))?;
1033
1034        form = form.part(file_field_name.to_string(), part);
1035
1036        let auth_header = self.generate_auth_header("POST", &url)?;
1037
1038        let response = self
1039            .client
1040            .post(&url)
1041            .header("Authorization", auth_header)
1042            .header("User-Agent", "Veracode Rust Client")
1043            .multipart(form)
1044            .send()
1045            .await?;
1046
1047        Ok(response)
1048    }
1049
1050    /// Upload a file using multipart form data with PUT method (for pipeline scans)
1051    ///
1052    /// # Arguments
1053    ///
1054    /// * `url` - The full URL to upload to
1055    /// * `file_field_name` - Name of the file field
1056    /// * `filename` - Name of the file
1057    /// * `file_data` - File data as bytes
1058    /// * `additional_headers` - Additional headers to include
1059    ///
1060    /// # Returns
1061    ///
1062    /// A `Result` containing the response or an error.
1063    ///
1064    /// # Errors
1065    ///
1066    /// Returns an error if the API request fails, the resource is not found,
1067    /// or authentication/authorization fails.
1068    pub async fn upload_file_multipart_put(
1069        &self,
1070        url: &str,
1071        file_field_name: &str,
1072        filename: &str,
1073        file_data: Vec<u8>,
1074        additional_headers: Option<HashMap<&str, &str>>,
1075    ) -> Result<reqwest::Response, VeracodeError> {
1076        // Build multipart form
1077        let part = multipart::Part::bytes(file_data)
1078            .file_name(filename.to_string())
1079            .mime_str("application/octet-stream")
1080            .map_err(|e| VeracodeError::InvalidConfig(e.to_string()))?;
1081
1082        let form = multipart::Form::new().part(file_field_name.to_string(), part);
1083
1084        let auth_header = self.generate_auth_header("PUT", url)?;
1085
1086        let mut request = self
1087            .client
1088            .put(url)
1089            .header("Authorization", auth_header)
1090            .header("User-Agent", "Veracode Rust Client")
1091            .multipart(form);
1092
1093        // Add any additional headers
1094        if let Some(headers) = additional_headers {
1095            for (key, value) in headers {
1096                request = request.header(key, value);
1097            }
1098        }
1099
1100        let response = request.send().await?;
1101        Ok(response)
1102    }
1103
1104    /// Upload a file with query parameters (like Java implementation)
1105    ///
1106    /// This method mimics the Java API wrapper's approach where parameters
1107    /// are added to the query string and the file is uploaded separately.
1108    ///
1109    /// Memory optimization: Uses Cow for strings and Arc for file data to minimize cloning
1110    /// during retry attempts. Automatically retries on transient failures.
1111    ///
1112    /// # Arguments
1113    ///
1114    /// * `endpoint` - The API endpoint to call
1115    /// * `query_params` - Query parameters as key-value pairs
1116    /// * `file_field_name` - Name of the file field
1117    /// * `filename` - Name of the file
1118    /// * `file_data` - File data as bytes
1119    ///
1120    /// # Returns
1121    ///
1122    /// A `Result` containing the response or an error.
1123    ///
1124    /// # Errors
1125    ///
1126    /// Returns an error if the API request fails, the resource is not found,
1127    /// or authentication/authorization fails.
1128    pub async fn upload_file_with_query_params(
1129        &self,
1130        endpoint: &str,
1131        query_params: &[(&str, &str)],
1132        file_field_name: &str,
1133        filename: &str,
1134        file_data: Vec<u8>,
1135    ) -> Result<reqwest::Response, VeracodeError> {
1136        // Build URL with query parameters using centralized helper for consistency
1137        let url = self.build_url_with_params(endpoint, query_params);
1138
1139        // Wrap file data in Arc to avoid cloning during retries
1140        let file_data_arc = Arc::new(file_data);
1141
1142        // Use Cow for strings to minimize allocations - borrow for short strings, own for long ones
1143        let filename_cow: Cow<str> = if filename.len() < 128 {
1144            Cow::Borrowed(filename)
1145        } else {
1146            Cow::Owned(filename.to_string())
1147        };
1148
1149        let field_name_cow: Cow<str> = if file_field_name.len() < 32 {
1150            Cow::Borrowed(file_field_name)
1151        } else {
1152            Cow::Owned(file_field_name.to_string())
1153        };
1154
1155        // Create request builder closure for retry logic
1156        let request_builder = || {
1157            // Clone Arc (cheap - just increments reference count)
1158            let file_data_clone = Arc::clone(&file_data_arc);
1159
1160            // Re-create multipart form for each attempt
1161            let Ok(part) = multipart::Part::bytes((*file_data_clone).clone())
1162                .file_name(filename_cow.to_string())
1163                .mime_str("application/octet-stream")
1164            else {
1165                return self.client.post("invalid://url");
1166            };
1167
1168            let form = multipart::Form::new().part(field_name_cow.to_string(), part);
1169
1170            // Re-generate auth header for each attempt to avoid signature expiry
1171            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
1172                return self.client.post("invalid://url");
1173            };
1174
1175            self.client
1176                .post(&url)
1177                .header("Authorization", auth_header)
1178                .header("User-Agent", "Veracode Rust Client")
1179                .multipart(form)
1180        };
1181
1182        // Use Cow for operation name based on endpoint length to minimize allocations
1183        let operation_name: Cow<str> = if endpoint.len() < 50 {
1184            Cow::Owned(format!("File Upload POST {endpoint}"))
1185        } else {
1186            Cow::Borrowed("File Upload POST [long endpoint]")
1187        };
1188
1189        self.execute_with_retry(request_builder, operation_name)
1190            .await
1191    }
1192
1193    /// Make a POST request with query parameters (like Java implementation for XML API)
1194    ///
1195    /// This method mimics the Java API wrapper's approach for POST operations
1196    /// where parameters are added to the query string rather than form data.
1197    ///
1198    /// # Arguments
1199    ///
1200    /// * `endpoint` - The API endpoint to call
1201    /// * `query_params` - Query parameters as key-value pairs
1202    ///
1203    /// # Returns
1204    ///
1205    /// A `Result` containing the response or an error.
1206    ///
1207    /// # Errors
1208    ///
1209    /// Returns an error if the API request fails, the resource is not found,
1210    /// or authentication/authorization fails.
1211    pub async fn post_with_query_params(
1212        &self,
1213        endpoint: &str,
1214        query_params: &[(&str, &str)],
1215    ) -> Result<reqwest::Response, VeracodeError> {
1216        // Build URL with query parameters using centralized helper
1217        let url = self.build_url_with_params(endpoint, query_params);
1218
1219        let auth_header = self.generate_auth_header("POST", &url)?;
1220
1221        let response = self
1222            .client
1223            .post(&url)
1224            .header("Authorization", auth_header)
1225            .header("User-Agent", "Veracode Rust Client")
1226            .send()
1227            .await?;
1228
1229        Ok(response)
1230    }
1231
1232    /// Make a GET request with query parameters (like Java implementation for XML API)
1233    ///
1234    /// This method mimics the Java API wrapper's approach for GET operations
1235    /// where parameters are added to the query string.
1236    ///
1237    /// # Arguments
1238    ///
1239    /// * `endpoint` - The API endpoint to call
1240    /// * `query_params` - Query parameters as key-value pairs
1241    ///
1242    /// # Returns
1243    ///
1244    /// A `Result` containing the response or an error.
1245    ///
1246    /// # Errors
1247    ///
1248    /// Returns an error if the API request fails, the resource is not found,
1249    /// or authentication/authorization fails.
1250    pub async fn get_with_query_params(
1251        &self,
1252        endpoint: &str,
1253        query_params: &[(&str, &str)],
1254    ) -> Result<reqwest::Response, VeracodeError> {
1255        // Build URL with query parameters using centralized helper
1256        let url = self.build_url_with_params(endpoint, query_params);
1257
1258        let auth_header = self.generate_auth_header("GET", &url)?;
1259
1260        let response = self
1261            .client
1262            .get(&url)
1263            .header("Authorization", auth_header)
1264            .header("User-Agent", "Veracode Rust Client")
1265            .send()
1266            .await?;
1267
1268        Ok(response)
1269    }
1270
1271    /// Upload a large file using chunked streaming (for uploadlargefile.do)
1272    ///
1273    /// This method implements chunked upload functionality similar to the Java API wrapper.
1274    /// It uploads files in chunks and provides progress tracking capabilities.
1275    ///
1276    /// # Arguments
1277    ///
1278    /// * `endpoint` - The API endpoint to call  
1279    /// * `query_params` - Query parameters as key-value pairs
1280    /// * `file_path` - Path to the file to upload
1281    /// * `content_type` - Content type for the file (default: binary/octet-stream)
1282    /// * `progress_callback` - Optional callback for progress tracking
1283    ///
1284    /// # Returns
1285    ///
1286    /// A `Result` containing the response or an error.
1287    ///
1288    /// # Errors
1289    ///
1290    /// Returns an error if the API request fails, the resource is not found,
1291    /// or authentication/authorization fails.
1292    pub async fn upload_large_file_chunked<F>(
1293        &self,
1294        endpoint: &str,
1295        query_params: &[(&str, &str)],
1296        file_path: &str,
1297        content_type: Option<&str>,
1298        progress_callback: Option<F>,
1299    ) -> Result<reqwest::Response, VeracodeError>
1300    where
1301        F: Fn(u64, u64, f64) + Send + Sync,
1302    {
1303        // Build URL with query parameters using centralized helper
1304        let url = self.build_url_with_params(endpoint, query_params);
1305
1306        // Open file and get size
1307        let mut file = File::open(file_path)
1308            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to open file: {e}")))?;
1309
1310        let file_size = file
1311            .metadata()
1312            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to get file size: {e}")))?
1313            .len();
1314
1315        // Check file size limit (2GB for uploadlargefile.do)
1316        #[allow(clippy::arithmetic_side_effects)]
1317        const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // 2GB
1318        if file_size > MAX_FILE_SIZE {
1319            return Err(VeracodeError::InvalidConfig(format!(
1320                "File size ({file_size} bytes) exceeds maximum limit of {MAX_FILE_SIZE} bytes"
1321            )));
1322        }
1323
1324        // Read entire file into memory for progress tracking and retry support
1325        file.seek(SeekFrom::Start(0))
1326            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to seek file: {e}")))?;
1327
1328        #[allow(clippy::cast_possible_truncation)]
1329        let mut file_data_vec = Vec::with_capacity(file_size as usize);
1330        file.read_to_end(&mut file_data_vec)
1331            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to read file: {e}")))?;
1332
1333        // Convert to Bytes for cheap cloning (Arc-backed internally)
1334        // This prevents expensive memory copies on each retry attempt
1335        let file_data = Bytes::from(file_data_vec);
1336        let content_type_cow: Cow<str> =
1337            content_type.map_or(Cow::Borrowed("binary/octet-stream"), |ct| {
1338                if ct.len() < 64 {
1339                    Cow::Borrowed(ct)
1340                } else {
1341                    Cow::Owned(ct.to_string())
1342                }
1343            });
1344
1345        // Create request builder closure for retry logic
1346        let request_builder = || {
1347            // Clone Bytes (cheap - Arc-backed internally, just increments reference count)
1348            let body_data = file_data.clone();
1349
1350            // Re-generate auth header for each attempt to avoid signature expiry
1351            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
1352                return self.client.post("invalid://url");
1353            };
1354
1355            self.client
1356                .post(&url)
1357                .header("Authorization", auth_header)
1358                .header("User-Agent", "Veracode Rust Client")
1359                .header("Content-Type", content_type_cow.as_ref())
1360                .header("Content-Length", file_size.to_string())
1361                .body(body_data)
1362        };
1363
1364        // Track progress if callback provided (before upload starts)
1365        if let Some(ref callback) = progress_callback {
1366            callback(0, file_size, 0.0);
1367        }
1368
1369        // Use optimized operation name
1370        let operation_name: Cow<str> = if endpoint.len() < 50 {
1371            Cow::Owned(format!("Large File Upload POST {endpoint}"))
1372        } else {
1373            Cow::Borrowed("Large File Upload POST [long endpoint]")
1374        };
1375
1376        let response = self
1377            .execute_with_retry(request_builder, operation_name)
1378            .await?;
1379
1380        // Track progress if callback provided (after upload completes)
1381        if let Some(callback) = progress_callback {
1382            callback(file_size, file_size, 100.0);
1383        }
1384
1385        Ok(response)
1386    }
1387
1388    /// Upload a file with binary data (optimized for uploadlargefile.do)
1389    ///
1390    /// This method uploads a file as raw binary data without multipart encoding,
1391    /// which is the expected format for the uploadlargefile.do endpoint.
1392    ///
1393    /// Memory optimization: Uses Arc for file data and Cow for strings to minimize
1394    /// allocations during retry attempts. Automatically retries on transient failures.
1395    ///
1396    /// # Arguments
1397    ///
1398    /// * `endpoint` - The API endpoint to call
1399    /// * `query_params` - Query parameters as key-value pairs  
1400    /// * `file_data` - File data as bytes
1401    /// * `content_type` - Content type for the file
1402    ///
1403    /// # Returns
1404    ///
1405    /// A `Result` containing the response or an error.
1406    ///
1407    /// # Errors
1408    ///
1409    /// Returns an error if the API request fails, the resource is not found,
1410    /// or authentication/authorization fails.
1411    pub async fn upload_file_binary(
1412        &self,
1413        endpoint: &str,
1414        query_params: &[(&str, &str)],
1415        file_data: Vec<u8>,
1416        content_type: &str,
1417    ) -> Result<reqwest::Response, VeracodeError> {
1418        // Build URL with query parameters using centralized helper
1419        let url = self.build_url_with_params(endpoint, query_params);
1420
1421        // Convert to Bytes for cheap cloning (Arc-backed internally)
1422        // This prevents expensive memory copies on each retry attempt
1423        let file_data = Bytes::from(file_data);
1424        let file_size = file_data.len();
1425
1426        // Use Cow for content type to minimize allocations
1427        let content_type_cow: Cow<str> = if content_type.len() < 64 {
1428            Cow::Borrowed(content_type)
1429        } else {
1430            Cow::Owned(content_type.to_string())
1431        };
1432
1433        // Create request builder closure for retry logic
1434        let request_builder = || {
1435            // Clone Bytes (cheap - Arc-backed internally, just increments reference count)
1436            let body_data = file_data.clone();
1437
1438            // Re-generate auth header for each attempt to avoid signature expiry
1439            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
1440                return self.client.post("invalid://url");
1441            };
1442
1443            self.client
1444                .post(&url)
1445                .header("Authorization", auth_header)
1446                .header("User-Agent", "Veracode Rust Client")
1447                .header("Content-Type", content_type_cow.as_ref())
1448                .header("Content-Length", file_size.to_string())
1449                .body(body_data)
1450        };
1451
1452        // Use optimized operation name based on endpoint length
1453        let operation_name: Cow<str> = if endpoint.len() < 50 {
1454            Cow::Owned(format!("Binary File Upload POST {endpoint}"))
1455        } else {
1456            Cow::Borrowed("Binary File Upload POST [long endpoint]")
1457        };
1458
1459        self.execute_with_retry(request_builder, operation_name)
1460            .await
1461    }
1462
1463    /// Upload a file with streaming (memory-efficient for large files)
1464    ///
1465    /// This method streams the file directly from disk without loading it entirely into memory.
1466    /// This is the recommended approach for large files (>100MB) to avoid high memory usage.
1467    ///
1468    /// # Arguments
1469    ///
1470    /// * `endpoint` - The API endpoint to call
1471    /// * `query_params` - Query parameters as key-value pairs
1472    /// * `file_path` - Path to the file to upload
1473    /// * `file_size` - Size of the file in bytes
1474    /// * `content_type` - Content type for the file
1475    ///
1476    /// # Returns
1477    ///
1478    /// A `Result` containing the response or an error.
1479    ///
1480    /// # Errors
1481    ///
1482    /// Returns an error if the API request fails, the resource is not found,
1483    /// or authentication/authorization fails.
1484    pub async fn upload_file_streaming(
1485        &self,
1486        endpoint: &str,
1487        query_params: &[(&str, &str)],
1488        file_path: &str,
1489        file_size: u64,
1490        content_type: &str,
1491    ) -> Result<reqwest::Response, VeracodeError> {
1492        // Build URL with query parameters using centralized helper
1493        let url = self.build_url_with_params(endpoint, query_params);
1494
1495        // Open file using tokio for async streaming
1496        let file = TokioFile::open(file_path)
1497            .await
1498            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to open file: {e}")))?;
1499
1500        // Create a stream from the file using tokio_util
1501        let stream = tokio_util::io::ReaderStream::new(file);
1502        let body = Body::wrap_stream(stream);
1503
1504        // Generate auth header
1505        let auth_header = self.generate_auth_header("POST", &url)?;
1506
1507        // Build and send the request (streaming upload - no retry support)
1508        // Note: Streaming bodies cannot be retried because the stream is consumed
1509        let response = self
1510            .client
1511            .post(&url)
1512            .header("Authorization", auth_header)
1513            .header("User-Agent", "Veracode Rust Client")
1514            .header("Content-Type", content_type)
1515            .header("Content-Length", file_size.to_string())
1516            .body(body)
1517            .send()
1518            .await
1519            .map_err(VeracodeError::Http)?;
1520
1521        Ok(response)
1522    }
1523}
1524
1525#[cfg(test)]
1526#[allow(clippy::expect_used)] // Test code: expect is acceptable for test setup
1527mod tests {
1528    use super::*;
1529    use proptest::prelude::*;
1530
1531    // ============================================================================
1532    // TIER 1: PROPERTY-BASED SECURITY TESTS (Fast, High ROI)
1533    // ============================================================================
1534
1535    /// Helper to create a test config with dummy credentials
1536    fn create_test_config() -> VeracodeConfig {
1537        use crate::{VeracodeCredentials, VeracodeRegion};
1538
1539        VeracodeConfig {
1540            credentials: VeracodeCredentials::new(
1541                "test_api_id".to_string(),
1542                "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
1543            ),
1544            base_url: "https://api.veracode.com".to_string(),
1545            rest_base_url: "https://api.veracode.com".to_string(),
1546            xml_base_url: "https://analysiscenter.veracode.com".to_string(),
1547            region: VeracodeRegion::Commercial,
1548            validate_certificates: true,
1549            connect_timeout: 30,
1550            request_timeout: 300,
1551            proxy_url: None,
1552            proxy_username: None,
1553            proxy_password: None,
1554            retry_config: Default::default(),
1555        }
1556    }
1557
1558    // ============================================================================
1559    // SECURITY TEST: URL Construction & Parameter Encoding
1560    // ============================================================================
1561
1562    proptest! {
1563        #![proptest_config(ProptestConfig {
1564            cases: if cfg!(miri) { 5 } else { 1000 },
1565            failure_persistence: None,
1566            .. ProptestConfig::default()
1567        })]
1568
1569        /// Property: URL parameter encoding must prevent injection attacks
1570        /// Tests that special characters are properly encoded and cannot break URL structure
1571        #[test]
1572        fn proptest_url_params_prevent_injection(
1573            key in "[a-zA-Z0-9_]{1,50}",
1574            value in ".*{0,100}",
1575        ) {
1576            let config = create_test_config();
1577            let client = VeracodeClient::new(config)
1578                .expect("valid test client configuration");
1579
1580            let params = vec![(key.as_str(), value.as_str())];
1581            let url = client.build_url_with_params("/api/test", &params);
1582
1583            // Property 1: URL must not contain unencoded dangerous characters
1584            prop_assert!(!url.contains("<script>"));
1585            prop_assert!(!url.contains("javascript:"));
1586
1587            // Property 2: URL must contain properly encoded parameters
1588            prop_assert!(url.starts_with("https://api.veracode.com/api/test"));
1589
1590            // Property 3: If params are present, URL must contain '?'
1591            if !params.is_empty() && !key.is_empty() {
1592                prop_assert!(url.contains('?'));
1593            }
1594        }
1595
1596        /// Property: URL construction must handle capacity overflow safely
1597        /// Tests that large numbers of parameters don't cause panics or overflows
1598        #[test]
1599        fn proptest_url_params_capacity_safe(
1600            param_count in 0usize..=100,
1601        ) {
1602            let config = create_test_config();
1603            let client = VeracodeClient::new(config)
1604                .expect("valid test client configuration");
1605
1606            // Create param_count parameters
1607            let params: Vec<(&str, &str)> = (0..param_count)
1608                .map(|_| ("key", "value"))
1609                .collect();
1610
1611            // Must not panic on capacity calculations
1612            let url = client.build_url_with_params("/api/test", &params);
1613
1614            // Property: URL should be valid and not panic
1615            prop_assert!(url.starts_with("https://"));
1616            prop_assert!(url.len() < 100000); // Reasonable upper bound
1617        }
1618
1619        /// Property: Empty and whitespace-only parameters are handled safely
1620        #[test]
1621        fn proptest_url_params_empty_safe(
1622            key in "\\s*",
1623            value in "\\s*",
1624        ) {
1625            let config = create_test_config();
1626            let client = VeracodeClient::new(config)
1627                .expect("valid test client configuration");
1628
1629            let params = vec![(key.as_str(), value.as_str())];
1630            let url = client.build_url_with_params("/api/test", &params);
1631
1632            // Must not panic and produce valid URL
1633            prop_assert!(url.starts_with("https://"));
1634        }
1635    }
1636
1637    // ============================================================================
1638    // SECURITY TEST: HMAC Signature Generation
1639    // ============================================================================
1640
1641    proptest! {
1642        #![proptest_config(ProptestConfig {
1643            cases: if cfg!(miri) { 5 } else { 1000 },
1644            failure_persistence: None,
1645            .. ProptestConfig::default()
1646        })]
1647
1648        /// Property: HMAC signature generation must handle invalid URLs gracefully
1649        /// Tests that malformed URLs return errors instead of panicking
1650        #[test]
1651        fn proptest_hmac_invalid_urls_return_error(
1652            invalid_url in ".*{0,100}",
1653        ) {
1654            let config = create_test_config();
1655            let client = VeracodeClient::new(config)
1656                .expect("valid test client configuration");
1657
1658            // Property: Invalid URLs must return Err, never panic
1659            let result = client.generate_hmac_signature(
1660                "GET",
1661                &invalid_url,
1662                1234567890000,
1663                "0123456789abcdef0123456789abcdef",
1664            );
1665
1666            // Either succeeds (if URL happens to be valid) or returns error
1667            match result {
1668                Ok(_) => {
1669                    // If it succeeded, the URL must have been parseable
1670                    prop_assert!(Url::parse(&invalid_url).is_ok());
1671                },
1672                Err(e) => {
1673                    // Error must be Authentication error
1674                    prop_assert!(matches!(e, VeracodeError::Authentication(_)));
1675                }
1676            }
1677        }
1678
1679        /// Property: HMAC signature must be deterministic
1680        /// Same inputs must always produce the same signature
1681        #[test]
1682        fn proptest_hmac_deterministic(
1683            method in "[A-Z]{3,7}",
1684            timestamp in 1000000000000u64..2000000000000u64,
1685        ) {
1686            let config = create_test_config();
1687            let client = VeracodeClient::new(config)
1688                .expect("valid test client configuration");
1689
1690            let url = "https://api.veracode.com/api/test";
1691            let nonce = "0123456789abcdef0123456789abcdef";
1692
1693            let sig1 = client.generate_hmac_signature(&method, url, timestamp, nonce);
1694            let sig2 = client.generate_hmac_signature(&method, url, timestamp, nonce);
1695
1696            // Property: Deterministic - same inputs produce same output
1697            match (sig1, sig2) {
1698                (Ok(s1), Ok(s2)) => prop_assert_eq!(s1, s2),
1699                (Err(_), Err(_)) => {}, // Both failed - also deterministic
1700                _ => prop_assert!(false, "Non-deterministic result"),
1701            }
1702        }
1703
1704        /// Property: Invalid hex nonce must return error
1705        /// Tests that non-hex nonces are rejected safely
1706        #[test]
1707        fn proptest_hmac_invalid_nonce_returns_error(
1708            invalid_nonce in "[^0-9a-fA-F]{1,32}",
1709        ) {
1710            let config = create_test_config();
1711            let client = VeracodeClient::new(config)
1712                .expect("valid test client configuration");
1713
1714            let result = client.generate_hmac_signature(
1715                "GET",
1716                "https://api.veracode.com/api/test",
1717                1234567890000,
1718                &invalid_nonce,
1719            );
1720
1721            // Property: Non-hex nonce must return Authentication error
1722            prop_assert!(matches!(result, Err(VeracodeError::Authentication(_))));
1723        }
1724
1725        /// Property: Timestamp overflow must be handled safely
1726        /// Tests edge cases in timestamp handling
1727        #[test]
1728        fn proptest_hmac_timestamp_safe(
1729            timestamp in any::<u64>(),
1730        ) {
1731            let config = create_test_config();
1732            let client = VeracodeClient::new(config)
1733                .expect("valid test client configuration");
1734
1735            let url = "https://api.veracode.com/api/test";
1736            let nonce = "0123456789abcdef0123456789abcdef";
1737
1738            // Must not panic on any timestamp value
1739            let result = client.generate_hmac_signature("GET", url, timestamp, nonce);
1740
1741            // Property: Either succeeds or returns error, never panics
1742            prop_assert!(result.is_ok() || result.is_err());
1743        }
1744    }
1745
1746    // ============================================================================
1747    // SECURITY TEST: Authentication Header Generation
1748    // ============================================================================
1749
1750    proptest! {
1751        #![proptest_config(ProptestConfig {
1752            cases: if cfg!(miri) { 5 } else { 1000 },
1753            failure_persistence: None,
1754            .. ProptestConfig::default()
1755        })]
1756
1757        /// Property: Auth header must contain all required components
1758        /// Tests that generated headers have proper VERACODE-HMAC-SHA-256 format
1759        #[test]
1760        fn proptest_auth_header_format(
1761            method in "[A-Z]{3,7}",
1762        ) {
1763            let config = create_test_config();
1764            let client = VeracodeClient::new(config)
1765                .expect("valid test client configuration");
1766
1767            let url = "https://api.veracode.com/api/test";
1768            let result = client.generate_auth_header(&method, url);
1769
1770            if let Ok(header) = result {
1771                // Property 1: Must start with correct prefix
1772                prop_assert!(header.starts_with("VERACODE-HMAC-SHA-256"));
1773
1774                // Property 2: Must contain all required fields
1775                prop_assert!(header.contains("id="));
1776                prop_assert!(header.contains("ts="));
1777                prop_assert!(header.contains("nonce="));
1778                prop_assert!(header.contains("sig="));
1779
1780                // Property 3: Fields must be comma-separated
1781                let parts: Vec<&str> = header.split(',').collect();
1782                prop_assert_eq!(parts.len(), 4);
1783            }
1784        }
1785
1786        /// Property: Auth header nonce must be unique and valid hex
1787        /// Tests that nonces are properly generated as 32-character hex strings
1788        #[test]
1789        fn proptest_auth_header_nonce_unique(
1790            _seed in any::<u8>(),
1791        ) {
1792            let config = create_test_config();
1793            let client = VeracodeClient::new(config)
1794                .expect("valid test client configuration");
1795
1796            let url = "https://api.veracode.com/api/test";
1797
1798            // Generate two headers
1799            let header1 = client.generate_auth_header("GET", url)
1800                .expect("valid auth header generation");
1801            let header2 = client.generate_auth_header("GET", url)
1802                .expect("valid auth header generation");
1803
1804            // Extract nonces using a helper function (avoids lifetime issues)
1805            fn extract_nonce(h: &str) -> Option<String> {
1806                Some(h.split("nonce=")
1807                    .nth(1)?
1808                    .split(',')
1809                    .next()?
1810                    .to_string())
1811            }
1812
1813            if let (Some(nonce1), Some(nonce2)) = (extract_nonce(&header1), extract_nonce(&header2)) {
1814                // Property 1: Nonces should be different (probabilistically)
1815                // With 128-bit random, collision is extremely unlikely
1816                prop_assert_ne!(&nonce1, &nonce2);
1817
1818                // Property 2: Nonces must be valid hex (32 chars for 16 bytes)
1819                prop_assert_eq!(nonce1.len(), 32);
1820                prop_assert_eq!(nonce2.len(), 32);
1821                prop_assert!(nonce1.chars().all(|c| c.is_ascii_hexdigit()));
1822                prop_assert!(nonce2.chars().all(|c| c.is_ascii_hexdigit()));
1823            }
1824        }
1825    }
1826
1827    // ============================================================================
1828    // SECURITY TEST: Configuration & Client Creation
1829    // ============================================================================
1830
1831    proptest! {
1832        #![proptest_config(ProptestConfig {
1833            cases: if cfg!(miri) { 5 } else { 100 },
1834            failure_persistence: None,
1835            .. ProptestConfig::default()
1836        })]
1837
1838        /// Property: Client creation with invalid config must fail gracefully
1839        /// Tests that invalid proxy URLs are caught during client creation
1840        #[test]
1841        fn proptest_client_creation_invalid_proxy(
1842            invalid_proxy in ".*{0,100}",
1843        ) {
1844            use crate::{VeracodeCredentials, VeracodeRegion};
1845
1846            let config = VeracodeConfig {
1847                credentials: VeracodeCredentials::new(
1848                    "test_api_id".to_string(),
1849                    "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
1850                ),
1851                base_url: "https://api.veracode.com".to_string(),
1852                rest_base_url: "https://api.veracode.com".to_string(),
1853                xml_base_url: "https://analysiscenter.veracode.com".to_string(),
1854                region: VeracodeRegion::Commercial,
1855                validate_certificates: true,
1856                connect_timeout: 30,
1857                request_timeout: 300,
1858                proxy_url: Some(invalid_proxy.clone()),
1859                proxy_username: None,
1860                proxy_password: None,
1861                retry_config: Default::default(),
1862            };
1863
1864            let result = VeracodeClient::new(config);
1865
1866            // Property: Either succeeds (if proxy URL is valid) or returns InvalidConfig error
1867            match result {
1868                Ok(_) => {
1869                    // If successful, proxy URL must be valid
1870                    prop_assert!(reqwest::Proxy::all(&invalid_proxy).is_ok());
1871                },
1872                Err(e) => {
1873                    // Must be InvalidConfig error
1874                    prop_assert!(matches!(e, VeracodeError::InvalidConfig(_)));
1875                }
1876            }
1877        }
1878
1879        /// Property: Timeout values must be handled safely
1880        /// Tests that extreme timeout values don't cause panics
1881        #[test]
1882        fn proptest_client_timeouts_safe(
1883            connect_timeout in 1u64..=3600,
1884            request_timeout in 1u64..=7200,
1885        ) {
1886            use crate::{VeracodeCredentials, VeracodeRegion};
1887
1888            let config = VeracodeConfig {
1889                credentials: VeracodeCredentials::new(
1890                    "test_api_id".to_string(),
1891                    "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
1892                ),
1893                base_url: "https://api.veracode.com".to_string(),
1894                rest_base_url: "https://api.veracode.com".to_string(),
1895                xml_base_url: "https://analysiscenter.veracode.com".to_string(),
1896                region: VeracodeRegion::Commercial,
1897                validate_certificates: true,
1898                connect_timeout,
1899                request_timeout,
1900                proxy_url: None,
1901                proxy_username: None,
1902                proxy_password: None,
1903                retry_config: Default::default(),
1904            };
1905
1906            // Must not panic on any valid timeout values
1907            let result = VeracodeClient::new(config);
1908            prop_assert!(result.is_ok());
1909        }
1910    }
1911
1912    // ============================================================================
1913    // SECURITY TEST: File Size Limits & Memory Safety
1914    // ============================================================================
1915
1916    proptest! {
1917        #![proptest_config(ProptestConfig {
1918            cases: if cfg!(miri) { 5 } else { 100 },
1919            failure_persistence: None,
1920            .. ProptestConfig::default()
1921        })]
1922
1923        /// Property: File size calculations must not overflow
1924        /// Tests that capacity calculations for file uploads are safe
1925        #[test]
1926        fn proptest_file_upload_capacity_safe(
1927            file_size in 0usize..=1000000,
1928        ) {
1929            // Create file data of specified size
1930            let file_data = vec![0u8; file_size];
1931
1932            // Wrap in Arc like the upload functions do
1933            let file_data_arc = Arc::new(file_data);
1934
1935            // Property 1: Length must match original size
1936            prop_assert_eq!(file_data_arc.len(), file_size);
1937
1938            // Property 2: Arc clone must be cheap (same allocation)
1939            let clone1 = Arc::clone(&file_data_arc);
1940            let clone2 = Arc::clone(&file_data_arc);
1941            prop_assert_eq!(clone1.len(), file_size);
1942            prop_assert_eq!(clone2.len(), file_size);
1943        }
1944
1945        /// Property: Content-Type handling must prevent injection
1946        /// Tests that content types are handled safely without code execution
1947        #[test]
1948        fn proptest_content_type_safe(
1949            content_type in ".*{0,200}",
1950        ) {
1951            // Test Cow allocation strategy
1952            let content_type_cow: Cow<str> = if content_type.len() < 64 {
1953                Cow::Borrowed(&content_type)
1954            } else {
1955                Cow::Owned(content_type.clone())
1956            };
1957
1958            // Property 1: Must not contain script injection attempts
1959            let ct_lower = content_type_cow.to_lowercase();
1960            if ct_lower.contains("<script>") || ct_lower.contains("javascript:") {
1961                // These should be treated as literal strings, not executed
1962                prop_assert!(content_type_cow.as_ref().contains("<script>") ||
1963                           content_type_cow.as_ref().contains("javascript:"));
1964            }
1965
1966            // Property 2: Length must be preserved
1967            prop_assert_eq!(content_type_cow.len(), content_type.len());
1968        }
1969    }
1970
1971    // ============================================================================
1972    // UNIT TESTS: Specific Security Scenarios
1973    // ============================================================================
1974
1975    #[test]
1976    fn test_hmac_signature_with_query_params() {
1977        let config = create_test_config();
1978        let client = VeracodeClient::new(config).expect("valid test client configuration");
1979
1980        // Test URL with query parameters
1981        let url = "https://api.veracode.com/api/test?param1=value1&param2=value2";
1982        let nonce = "0123456789abcdef0123456789abcdef";
1983        let timestamp = 1234567890000;
1984
1985        let result = client.generate_hmac_signature("GET", url, timestamp, nonce);
1986        assert!(result.is_ok());
1987
1988        let signature = result.expect("valid HMAC signature");
1989        // HMAC signature should be 64 hex characters (32 bytes * 2)
1990        assert_eq!(signature.len(), 64);
1991        assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
1992    }
1993
1994    #[test]
1995    fn test_hmac_signature_different_methods() {
1996        let config = create_test_config();
1997        let client = VeracodeClient::new(config).expect("valid test client configuration");
1998
1999        let url = "https://api.veracode.com/api/test";
2000        let nonce = "0123456789abcdef0123456789abcdef";
2001        let timestamp = 1234567890000;
2002
2003        let sig_get = client
2004            .generate_hmac_signature("GET", url, timestamp, nonce)
2005            .expect("valid HMAC signature for GET");
2006        let sig_post = client
2007            .generate_hmac_signature("POST", url, timestamp, nonce)
2008            .expect("valid HMAC signature for POST");
2009
2010        // Different methods should produce different signatures
2011        assert_ne!(sig_get, sig_post);
2012    }
2013
2014    #[test]
2015    fn test_url_encoding_special_characters() {
2016        let config = create_test_config();
2017        let client = VeracodeClient::new(config).expect("valid test client configuration");
2018
2019        // Test that special characters are properly encoded
2020        let params = vec![
2021            ("key1", "value with spaces"),
2022            ("key2", "value&with&ampersands"),
2023            ("key3", "value=with=equals"),
2024            ("key4", "value?with?questions"),
2025        ];
2026
2027        let url = client.build_url_with_params("/api/test", &params);
2028
2029        // URL should contain encoded spaces
2030        assert!(url.contains("value%20with%20spaces") || url.contains("value+with+spaces"));
2031        // URL should contain encoded ampersands
2032        assert!(url.contains("%26"));
2033        // URL should start with base URL
2034        assert!(url.starts_with("https://api.veracode.com/api/test?"));
2035    }
2036
2037    #[test]
2038    fn test_url_encoding_unicode() {
2039        let config = create_test_config();
2040        let client = VeracodeClient::new(config).expect("valid test client configuration");
2041
2042        // Test Unicode handling
2043        let params = vec![
2044            ("key", "δ½ ε₯½δΈ–η•Œ"), // Chinese characters
2045            ("key2", "πŸ”’πŸ›‘οΈ"),    // Emojis
2046        ];
2047
2048        let url = client.build_url_with_params("/api/test", &params);
2049
2050        // Must not panic and should produce valid URL
2051        assert!(url.starts_with("https://api.veracode.com/api/test?"));
2052        // URL should contain percent-encoded Unicode
2053        assert!(url.contains('%'));
2054    }
2055
2056    #[test]
2057    fn test_empty_query_params() {
2058        let config = create_test_config();
2059        let client = VeracodeClient::new(config).expect("valid test client configuration");
2060
2061        let url = client.build_url_with_params("/api/test", &[]);
2062
2063        // Empty params should not add '?'
2064        assert_eq!(url, "https://api.veracode.com/api/test");
2065    }
2066
2067    #[test]
2068    fn test_invalid_api_key_format() {
2069        use crate::{VeracodeCredentials, VeracodeRegion};
2070
2071        // Create config with non-hex API key
2072        let config = VeracodeConfig {
2073            credentials: VeracodeCredentials::new(
2074                "test_api_id".to_string(),
2075                "not_valid_hex_key".to_string(),
2076            ),
2077            base_url: "https://api.veracode.com".to_string(),
2078            rest_base_url: "https://api.veracode.com".to_string(),
2079            xml_base_url: "https://analysiscenter.veracode.com".to_string(),
2080            region: VeracodeRegion::Commercial,
2081            validate_certificates: true,
2082            connect_timeout: 30,
2083            request_timeout: 300,
2084            proxy_url: None,
2085            proxy_username: None,
2086            proxy_password: None,
2087            retry_config: Default::default(),
2088        };
2089
2090        let client = VeracodeClient::new(config).expect("valid test client configuration");
2091        let result = client.generate_auth_header("GET", "https://api.veracode.com/api/test");
2092
2093        // Should return Authentication error for invalid hex key
2094        assert!(matches!(result, Err(VeracodeError::Authentication(_))));
2095    }
2096
2097    #[test]
2098    fn test_auth_header_format() {
2099        let config = create_test_config();
2100        let client = VeracodeClient::new(config).expect("valid test client configuration");
2101
2102        let header = client
2103            .generate_auth_header("GET", "https://api.veracode.com/api/test")
2104            .expect("valid auth header generation");
2105
2106        // Verify format
2107        assert!(header.starts_with("VERACODE-HMAC-SHA-256 "));
2108        assert!(header.contains("id=test_api_id"));
2109        assert!(header.contains("ts="));
2110        assert!(header.contains("nonce="));
2111        assert!(header.contains("sig="));
2112
2113        // Verify structure (should have 4 comma-separated parts after prefix)
2114        let parts: Vec<&str> = header.split(',').collect();
2115        assert_eq!(parts.len(), 4);
2116    }
2117
2118    #[cfg(not(miri))] // Skip under Miri - uses SystemTime
2119    #[test]
2120    fn test_auth_header_timestamp_monotonic() {
2121        let config = create_test_config();
2122        let client = VeracodeClient::new(config).expect("valid test client configuration");
2123
2124        let header1 = client
2125            .generate_auth_header("GET", "https://api.veracode.com/api/test")
2126            .expect("valid auth header generation");
2127        std::thread::sleep(std::time::Duration::from_millis(10));
2128        let header2 = client
2129            .generate_auth_header("GET", "https://api.veracode.com/api/test")
2130            .expect("valid auth header generation");
2131
2132        // Extract timestamps
2133        let extract_ts =
2134            |h: &str| -> Option<u64> { h.split("ts=").nth(1)?.split(',').next()?.parse().ok() };
2135
2136        let ts1 = extract_ts(&header1).expect("valid timestamp extraction");
2137        let ts2 = extract_ts(&header2).expect("valid timestamp extraction");
2138
2139        // Second timestamp should be >= first (monotonic)
2140        assert!(ts2 >= ts1);
2141    }
2142
2143    #[test]
2144    fn test_base_url_accessor() {
2145        let config = create_test_config();
2146        let client = VeracodeClient::new(config).expect("valid test client configuration");
2147
2148        assert_eq!(client.base_url(), "https://api.veracode.com");
2149    }
2150
2151    #[test]
2152    fn test_client_clone() {
2153        let config = create_test_config();
2154        let client1 = VeracodeClient::new(config).expect("valid test client configuration");
2155        let client2 = client1.clone();
2156
2157        // Both clients should have same base URL
2158        assert_eq!(client1.base_url(), client2.base_url());
2159    }
2160
2161    #[test]
2162    fn test_url_capacity_estimation() {
2163        let config = create_test_config();
2164        let client = VeracodeClient::new(config).expect("valid test client configuration");
2165
2166        // Test with large number of parameters
2167        let params: Vec<(&str, &str)> = (0..100).map(|_| ("key", "value")).collect();
2168
2169        let url = client.build_url_with_params("/api/test", &params);
2170
2171        // Should handle large param counts without panic
2172        assert!(url.starts_with("https://api.veracode.com/api/test?"));
2173        assert!(url.len() > 100); // Should contain all params
2174    }
2175
2176    #[test]
2177    fn test_saturating_arithmetic() {
2178        let config = create_test_config();
2179        let client = VeracodeClient::new(config).expect("valid test client configuration");
2180
2181        // Test saturating_add in capacity calculation
2182        let params: Vec<(&str, &str)> = vec![("k", "v"); 1000];
2183
2184        // Should not panic even with large numbers
2185        let url = client.build_url_with_params("/api/test", &params);
2186        assert!(url.len() < usize::MAX);
2187    }
2188}