Skip to main content

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